From 393554e469fb3cf24d02e62b318c1ce25e4f0f33 Mon Sep 17 00:00:00 2001 From: David Hale Date: Thu, 29 Jan 2026 22:54:23 -0800 Subject: [PATCH 01/74] sequencer target_qc_check ensures nexp >= 1 --- sequencerd/sequencer_interface.cpp | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/sequencerd/sequencer_interface.cpp b/sequencerd/sequencer_interface.cpp index e85740d9..1550662d 100644 --- a/sequencerd/sequencer_interface.cpp +++ b/sequencerd/sequencer_interface.cpp @@ -689,7 +689,7 @@ namespace Sequencer { // if ( ( this->ra_hms.empty() && ! this->dec_dms.empty() ) || ( ! this->ra_hms.empty() && this->dec_dms.empty() ) ) { - message.str(""); message << "ERROR cannot have only RA or only DEC empty. both must be empty or filled"; + message << "ERROR cannot have only RA or only DEC empty. both must be empty or filled"; status = message.str(); logwrite( function, message.str() ); return ERROR; @@ -700,7 +700,7 @@ namespace Sequencer { if ( ! this->ra_hms.empty() ) { double _rah = radec_to_decimal( this->ra_hms ); // convert RA from HH:MM:SS.s to decimal hours if ( _rah < 0 ) { - message.str(""); message << "ERROR cannot have negative RA " << this->ra_hms; + message << "ERROR cannot have negative RA " << this->ra_hms; status = message.str(); logwrite( function, message.str() ); return ERROR; @@ -712,7 +712,7 @@ namespace Sequencer { if ( ! this->dec_dms.empty() ) { double _dec = radec_to_decimal( this->dec_dms ); // convert DEC from DD:MM:SS.s to decimal degrees if ( _dec < -90.0 || _dec > 90.0 ) { - message.str(""); message << "ERROR declination " << this->dec_dms << " outside range {-90:+90}"; + message << "ERROR declination " << this->dec_dms << " outside range {-90:+90}"; status = message.str(); logwrite( function, message.str() ); return ERROR; @@ -727,14 +727,18 @@ namespace Sequencer { else { if ( ! caseCompareString( this->pointmode, Acam::POINTMODE_ACAM ) && ! caseCompareString( this->pointmode, Acam::POINTMODE_SLIT ) ) { - message.str(""); message << "ERROR invalid pointmode \"" << this->pointmode << "\": must be { " - << Acam::POINTMODE_ACAM << " " << Acam::POINTMODE_SLIT << " }"; + message << "ERROR invalid pointmode \"" << this->pointmode << "\": must be { " + << Acam::POINTMODE_ACAM << " " << Acam::POINTMODE_SLIT << " }"; status = message.str(); logwrite( function, message.str() ); return ERROR; } } + // number of exposures must be >= 1 + // + if (this->nexp <= 0) this->nexp=1; + return NO_ERROR; } /***** Sequencer::TargetInfo::target_qc_check *******************************/ From 52c37925598f45360b1b1b01759b5e84513a3212 Mon Sep 17 00:00:00 2001 From: David Hale Date: Thu, 29 Jan 2026 22:57:06 -0800 Subject: [PATCH 02/74] adds capability of disabling camerad controllers, while leaving them powered and waveforms loaded. The biases are turned off and commands are not sent while inactive. untested --- camerad/astrocam.cpp | 958 +++++++++++++++++++++++--------------- camerad/astrocam.h | 35 +- camerad/camerad.cpp | 12 + common/camerad_commands.h | 6 + 4 files changed, 620 insertions(+), 391 deletions(-) diff --git a/camerad/astrocam.cpp b/camerad/astrocam.cpp index 594dbf67..9328c144 100644 --- a/camerad/astrocam.cpp +++ b/camerad/astrocam.cpp @@ -159,25 +159,25 @@ namespace AstroCam { return; } - server.controller[devnum].in_frametransfer = false; + server.controller.at(devnum).in_frametransfer = false; server.exposure_pending( devnum, false ); // this also does the notify - server.controller[devnum].in_readout = true; + server.controller.at(devnum).in_readout = true; server.state_monitor_condition.notify_all(); // Trigger the readout waveforms here. // try { - server.controller[devnum].pArcDev->readout( expbuf, + server.controller.at(devnum).pArcDev->readout( expbuf, devnum, - server.controller[devnum].info.axes[_ROW_], - server.controller[devnum].info.axes[_COL_], + server.controller.at(devnum).info.axes[_ROW_], + server.controller.at(devnum).info.axes[_COL_], server.camera.abortstate, - server.controller[devnum].pCallback + server.controller.at(devnum).pCallback ); } catch ( const std::exception &e ) { // arc::gen3::CArcDevice::readout may throw an exception - message.str(""); message << "ERROR starting readout for " << server.controller[devnum].devname - << " channel " << server.controller[devnum].channel << ": " << e.what(); + message.str(""); message << "ERROR starting readout for " << server.controller.at(devnum).devname + << " channel " << server.controller.at(devnum).channel << ": " << e.what(); std::thread( std::ref(AstroCam::Interface::handle_queue), message.str() ).detach(); return; } @@ -253,7 +253,7 @@ namespace AstroCam { void Interface::state_monitor_thread(Interface& interface) { std::string function = "AstroCam::Interface::state_monitor_thread"; std::stringstream message; - std::vector selectdev; + std::vector selectdev; // notify that the thread is running // @@ -272,11 +272,11 @@ namespace AstroCam { while ( interface.is_camera_idle() ) { selectdev.clear(); message.str(""); message << "enabling detector idling for channel(s)"; - for ( const auto &dev : interface.devnums ) { + for ( const auto &dev : interface.active_devnums ) { logwrite(function, std::to_string(dev)); - if ( interface.controller[dev].connected ) { + if ( interface.controller.at(dev).connected ) { selectdev.push_back(dev); - message << " " << interface.controller[dev].channel; + message << " " << interface.controller.at(dev).channel; } } if ( selectdev.size() > 0 ) { @@ -308,30 +308,30 @@ namespace AstroCam { std::string function = "AstroCam::Interface::make_image_keywords"; std::stringstream message; - auto chan = this->controller[dev].channel; + auto chan = this->controller.at(dev).channel; - auto rows = this->controller[dev].info.axes[_ROW_]; - auto cols = this->controller[dev].info.axes[_COL_]; - auto osrows = this->controller[dev].osrows; - auto oscols = this->controller[dev].oscols; - auto skiprows = this->controller[dev].skiprows; - auto skipcols = this->controller[dev].skipcols; + auto rows = this->controller.at(dev).info.axes[_ROW_]; + auto cols = this->controller.at(dev).info.axes[_COL_]; + auto osrows = this->controller.at(dev).osrows; + auto oscols = this->controller.at(dev).oscols; + auto skiprows = this->controller.at(dev).skiprows; + auto skipcols = this->controller.at(dev).skipcols; int binspec, binspat; - this->controller[dev].physical_to_logical(this->controller[dev].info.binning[_ROW_], - this->controller[dev].info.binning[_COL_], + this->controller.at(dev).physical_to_logical(this->controller.at(dev).info.binning[_ROW_], + this->controller.at(dev).info.binning[_COL_], binspat, binspec); - this->controller[dev].info.systemkeys.add_key( "AMP_ID", this->controller[dev].info.readout_name, "readout amplifier", EXT, chan ); - this->controller[dev].info.systemkeys.add_key( "FT", this->controller[dev].have_ft, "frame transfer used", EXT, chan ); + this->controller.at(dev).info.systemkeys.add_key( "AMP_ID", this->controller.at(dev).info.readout_name, "readout amplifier", EXT, chan ); + this->controller.at(dev).info.systemkeys.add_key( "FT", this->controller.at(dev).have_ft, "frame transfer used", EXT, chan ); - this->controller[dev].info.systemkeys.add_key( "IMG_ROWS", this->controller[dev].info.axes[_ROW_], "image rows", EXT, chan ); - this->controller[dev].info.systemkeys.add_key( "IMG_COLS", this->controller[dev].info.axes[_COL_]-oscols, "image cols", EXT, chan ); + this->controller.at(dev).info.systemkeys.add_key( "IMG_ROWS", this->controller.at(dev).info.axes[_ROW_], "image rows", EXT, chan ); + this->controller.at(dev).info.systemkeys.add_key( "IMG_COLS", this->controller.at(dev).info.axes[_COL_]-oscols, "image cols", EXT, chan ); - this->controller[dev].info.systemkeys.add_key( "OS_ROWS", osrows, "overscan rows", EXT, chan ); - this->controller[dev].info.systemkeys.add_key( "OS_COLS", oscols, "overscan cols", EXT, chan ); - this->controller[dev].info.systemkeys.add_key( "SKIPROWS", skiprows, "skipped rows", EXT, chan ); - this->controller[dev].info.systemkeys.add_key( "SKIPCOLS", skipcols, "skipped cols", EXT, chan ); + this->controller.at(dev).info.systemkeys.add_key( "OS_ROWS", osrows, "overscan rows", EXT, chan ); + this->controller.at(dev).info.systemkeys.add_key( "OS_COLS", oscols, "overscan cols", EXT, chan ); + this->controller.at(dev).info.systemkeys.add_key( "SKIPROWS", skiprows, "skipped rows", EXT, chan ); + this->controller.at(dev).info.systemkeys.add_key( "SKIPCOLS", skipcols, "skipped cols", EXT, chan ); int L=0, B=0; switch ( this->controller[ dev ].info.readout_type ) { @@ -351,57 +351,57 @@ namespace AstroCam { // << " ltv2=" << ltv2 << " ltv1=" << ltv1; //logwrite(function,message.str() ); - this->controller[dev].info.systemkeys.add_key( "LTV2", ltv2, "", EXT, chan ); - this->controller[dev].info.systemkeys.add_key( "LTV1", ltv1, "", EXT, chan ); - this->controller[dev].info.systemkeys.add_key( "CRPIX1A", ltv1+1, "", EXT, chan ); - this->controller[dev].info.systemkeys.add_key( "CRPIX2A", ltv2+1, "", EXT, chan ); + this->controller.at(dev).info.systemkeys.add_key( "LTV2", ltv2, "", EXT, chan ); + this->controller.at(dev).info.systemkeys.add_key( "LTV1", ltv1, "", EXT, chan ); + this->controller.at(dev).info.systemkeys.add_key( "CRPIX1A", ltv1+1, "", EXT, chan ); + this->controller.at(dev).info.systemkeys.add_key( "CRPIX2A", ltv2+1, "", EXT, chan ); - this->controller[dev].info.systemkeys.add_key( "BINSPEC", binspec, "binning in spectral direction", EXT, chan ); - this->controller[dev].info.systemkeys.add_key( "BINSPAT", binspat, "binning in spatial direction", EXT, chan ); + this->controller.at(dev).info.systemkeys.add_key( "BINSPEC", binspec, "binning in spectral direction", EXT, chan ); + this->controller.at(dev).info.systemkeys.add_key( "BINSPAT", binspat, "binning in spatial direction", EXT, chan ); - this->controller[dev].info.systemkeys.add_key( "CDELT1A", - this->controller[dev].info.dispersion*binspec, + this->controller.at(dev).info.systemkeys.add_key( "CDELT1A", + this->controller.at(dev).info.dispersion*binspec, "Dispersion in Angstrom/pixel", EXT, chan ); - this->controller[dev].info.systemkeys.add_key( "CRVAL1A", - this->controller[dev].info.minwavel, + this->controller.at(dev).info.systemkeys.add_key( "CRVAL1A", + this->controller.at(dev).info.minwavel, "Reference value in Angstrom", EXT, chan ); // These keys are for proper mosaic display. // Adjust GAPY to taste. // int GAPY=20; - int crval2 = ( this->controller[dev].info.axes[_ROW_] / binspat + GAPY ) * dev; + int crval2 = ( this->controller.at(dev).info.axes[_ROW_] / binspat + GAPY ) * dev; - this->controller[dev].info.systemkeys.add_key( "CRPIX1", 0, "", EXT, chan ); - this->controller[dev].info.systemkeys.add_key( "CRPIX2", 0, "", EXT, chan ); - this->controller[dev].info.systemkeys.add_key( "CRVAL1", 0, "", EXT, chan ); - this->controller[dev].info.systemkeys.add_key( "CRVAL2", crval2, "", EXT, chan ); + this->controller.at(dev).info.systemkeys.add_key( "CRPIX1", 0, "", EXT, chan ); + this->controller.at(dev).info.systemkeys.add_key( "CRPIX2", 0, "", EXT, chan ); + this->controller.at(dev).info.systemkeys.add_key( "CRVAL1", 0, "", EXT, chan ); + this->controller.at(dev).info.systemkeys.add_key( "CRVAL2", crval2, "", EXT, chan ); // Add ___SEC keywords to the extension header for this channel // std::stringstream sec; /* 01-24-2025 *** - sec.str(""); sec << "[" << this->controller[dev].info.region_of_interest[0] << ":" << this->controller[dev].info.region_of_interest[1] - << "," << this->controller[dev].info.region_of_interest[2] << ":" << this->controller[dev].info.region_of_interest[3] << "]"; - this->controller[dev].info.systemkeys.add_key( "CCDSEC", sec.str(), "physical format of CCD", EXT, chan ); + sec.str(""); sec << "[" << this->controller.at(dev).info.region_of_interest[0] << ":" << this->controller.at(dev).info.region_of_interest[1] + << "," << this->controller.at(dev).info.region_of_interest[2] << ":" << this->controller.at(dev).info.region_of_interest[3] << "]"; + this->controller.at(dev).info.systemkeys.add_key( "CCDSEC", sec.str(), "physical format of CCD", EXT, chan ); - sec.str(""); sec << "[" << this->controller[dev].info.region_of_interest[0] + skipcols << ":" << cols - << "," << this->controller[dev].info.region_of_interest[2] + skiprows << ":" << rows << "]"; - this->controller[dev].info.systemkeys.add_key( "DATASEC", sec.str(), "section containing the CCD data", EXT, chan ); + sec.str(""); sec << "[" << this->controller.at(dev).info.region_of_interest[0] + skipcols << ":" << cols + << "," << this->controller.at(dev).info.region_of_interest[2] + skiprows << ":" << rows << "]"; + this->controller.at(dev).info.systemkeys.add_key( "DATASEC", sec.str(), "section containing the CCD data", EXT, chan ); sec.str(""); sec << '[' << cols << ":" << cols+oscols - << "," << this->controller[dev].info.region_of_interest[2] + skiprows << ":" << rows+osrows << "]"; - this->controller[dev].info.systemkeys.add_key( "BIASSEC", sec.str(), "overscan section", EXT, chan ); + << "," << this->controller.at(dev).info.region_of_interest[2] + skiprows << ":" << rows+osrows << "]"; + this->controller.at(dev).info.systemkeys.add_key( "BIASSEC", sec.str(), "overscan section", EXT, chan ); *** */ sec.str(""); sec << "[" << oscols+1-2 // -2 is KLUDGE FACTOR << ":" << cols-2 // -2 is KLUDGE FACTOR - << "," << this->controller[dev].info.region_of_interest[2] + skiprows << ":" << rows << "]"; - this->controller[dev].info.systemkeys.add_key( "DATASEC", sec.str(), "section containing the CCD data", EXT, chan ); + << "," << this->controller.at(dev).info.region_of_interest[2] + skiprows << ":" << rows << "]"; + this->controller.at(dev).info.systemkeys.add_key( "DATASEC", sec.str(), "section containing the CCD data", EXT, chan ); - sec.str(""); sec << "[" << this->controller[dev].info.region_of_interest[0] << ":" << oscols-2 // -2 is KLUDGE FACTOR - << "," << this->controller[dev].info.region_of_interest[2] + skiprows << ":" << rows << "]"; - this->controller[dev].info.systemkeys.add_key( "BIASSEC", sec.str(), "overscan section", EXT, chan ); + sec.str(""); sec << "[" << this->controller.at(dev).info.region_of_interest[0] << ":" << oscols-2 // -2 is KLUDGE FACTOR + << "," << this->controller.at(dev).info.region_of_interest[2] + skiprows << ":" << rows << "]"; + this->controller.at(dev).info.systemkeys.add_key( "BIASSEC", sec.str(), "overscan section", EXT, chan ); return; } @@ -428,8 +428,8 @@ namespace AstroCam { // Parse the three values from the args string try { int dev = devnum_from_chan(tokens.at(0)); - this->controller[dev].info.dispersion = std::stod(tokens.at(1)); - this->controller[dev].info.minwavel = std::stod(tokens.at(2)); + this->controller.at(dev).info.dispersion = std::stod(tokens.at(1)); + this->controller.at(dev).info.minwavel = std::stod(tokens.at(2)); } catch(const std::exception &e) { logwrite(function, "ERROR parsing SPEC_INFO config: "+std::string(e.what())); @@ -473,8 +473,8 @@ namespace AstroCam { } if (spec==spat) throw std::runtime_error("PHYSSPAT/PHYSSPEC must be unique"); - this->controller[dev].spat_axis = (spat=="ROW" ? Controller::ROW : Controller::COL); - this->controller[dev].spec_axis = (spec=="ROW" ? Controller::ROW : Controller::COL); + this->controller.at(dev).spat_axis = (spat=="ROW" ? Controller::ROW : Controller::COL); + this->controller.at(dev).spec_axis = (spec=="ROW" ? Controller::ROW : Controller::COL); } catch(const std::exception &e) { logwrite(function, "ERROR parsing DETECTOR_GEOMETRY config: "+std::string(e.what())); @@ -595,53 +595,59 @@ namespace AstroCam { // // The first four come from the config file, the rest are defaults // - this->controller[dev].devnum = dev; // device number - this->controller[dev].channel = chan; // spectrographic channel - this->controller[dev].ccd_id = id; // CCD identifier - this->controller[dev].have_ft = ft; // frame transfer supported? - this->controller[dev].firmware = firm; // firmware file + this->controller.at(dev).devnum = dev; // device number + this->controller.at(dev).channel = chan; // spectrographic channel + this->controller.at(dev).ccd_id = id; // CCD identifier + this->controller.at(dev).have_ft = ft; // frame transfer supported? + this->controller.at(dev).firmware = firm; // firmware file /* arc::gen3::CArcDevice* pArcDev = NULL; // create a generic CArcDevice pointer pArcDev = new arc::gen3::CArcPCI(); // point this at a new CArcPCI() object ///< TODO @todo implement PCIe option Callback* pCB = new Callback(); // create a pointer to a Callback() class object - this->controller[dev].pArcDev = pArcDev; // set the pointer to this object in the public vector - this->controller[dev].pCallback = pCB; // set the pointer to this object in the public vector + this->controller.at(dev).pArcDev = pArcDev; // set the pointer to this object in the public vector + this->controller.at(dev).pCallback = pCB; // set the pointer to this object in the public vector */ - this->controller[dev].pArcDev = ( new arc::gen3::CArcPCI() ); // set the pointer to this object in the public vector - this->controller[dev].pCallback = ( new Callback() ); // set the pointer to this object in the public vector - this->controller[dev].devname = ""; // device name - this->controller[dev].connected = false; // not yet connected - this->controller[dev].is_imsize_set = false; // need to set image_size - this->controller[dev].firmwareloaded = false; // no firmware loaded - this->controller[dev].inactive = false; // assume active unless shown otherwise - - this->controller[dev].info.readout_name = amp; - this->controller[dev].info.readout_type = readout_type; - this->controller[dev].readout_arg = readout_arg; + this->controller.at(dev).pArcDev = ( new arc::gen3::CArcPCI() ); // set the pointer to this object in the public vector + this->controller.at(dev).pCallback = ( new Callback() ); // set the pointer to this object in the public vector + this->controller.at(dev).devname = ""; // device name + this->controller.at(dev).connected = false; // not yet connected + this->controller.at(dev).is_imsize_set = false; // need to set image_size + this->controller.at(dev).firmwareloaded = false; // no firmware loaded + + this->controller.at(dev).info.readout_name = amp; + this->controller.at(dev).info.readout_type = readout_type; + this->controller.at(dev).readout_arg = readout_arg; this->exposure_pending( dev, false ); - this->controller[dev].in_readout = false; - this->controller[dev].in_frametransfer = false; + this->controller.at(dev).in_readout = false; + this->controller.at(dev).in_frametransfer = false; this->state_monitor_condition.notify_all(); + // configured by config file, can never be reversed unless it is removed from the config file + this->controller.at(dev).configured = true; + + // configured and active. this state can be reversed on command or failure to connect + // active alone isn't connected, but if not connected then it's not active + this->controller.at(dev).active = true; + // Header keys specific to this controller are stored in the controller's extension // - this->controller[dev].info.systemkeys.add_key( "CCD_ID", id, "CCD identifier parse", EXT, chan ); - this->controller[dev].info.systemkeys.add_key( "FT", ft, "frame transfer used", EXT, chan ); - this->controller[dev].info.systemkeys.add_key( "AMP_ID", amp, "CCD readout amplifier ID", EXT, chan ); - this->controller[dev].info.systemkeys.add_key( "SPEC_ID", chan, "spectrograph channel", EXT, chan ); - this->controller[dev].info.systemkeys.add_key( "DEV_ID", dev, "detector controller PCI device ID", EXT, chan ); + this->controller.at(dev).info.systemkeys.add_key( "CCD_ID", id, "CCD identifier parse", EXT, chan ); + this->controller.at(dev).info.systemkeys.add_key( "FT", ft, "frame transfer used", EXT, chan ); + this->controller.at(dev).info.systemkeys.add_key( "AMP_ID", amp, "CCD readout amplifier ID", EXT, chan ); + this->controller.at(dev).info.systemkeys.add_key( "SPEC_ID", chan, "spectrograph channel", EXT, chan ); + this->controller.at(dev).info.systemkeys.add_key( "DEV_ID", dev, "detector controller PCI device ID", EXT, chan ); // FITS_file* pFits = new FITS_file(); // create a pointer to a FITS_file class object -// this->controller[dev].pFits = pFits; // set the pointer to this object in the public vector +// this->controller.at(dev).pFits = pFits; // set the pointer to this object in the public vector #ifdef LOGLEVEL_DEBUG message.str(""); message << "[DEBUG] pointers for dev " << dev << ": " - << " pArcDev=" << std::hex << std::uppercase << this->controller[dev].pArcDev - << " pCB=" << std::hex << std::uppercase << this->controller[dev].pCallback; + << " pArcDev=" << std::hex << std::uppercase << this->controller.at(dev).pArcDev + << " pCB=" << std::hex << std::uppercase << this->controller.at(dev).pCallback; logwrite(function, message.str()); #endif return( NO_ERROR ); @@ -660,7 +666,7 @@ namespace AstroCam { int Interface::devnum_from_chan( const std::string &chan ) { int devnum=-1; for ( const auto &con : this->controller ) { - if ( con.second.inactive ) continue; // skip controllers flagged as inactive + if ( !con.second.configured ) continue; // skip controllers not configured if ( con.second.channel == chan ) { // check to see if it matches a configured channel. devnum = con.second.devnum; break; @@ -746,10 +752,10 @@ namespace AstroCam { for ( const auto &con : this->controller ) { #ifdef LOGLEVEL_DEBUG message.str(""); message << "[DEBUG] con.first=" << con.first << " con.second.channel=" << con.second.channel - << " .devnum=" << con.second.devnum << " .inactive=" << (con.second.inactive?"T":"F"); + << " .devnum=" << con.second.devnum << " .configured=" << (con.second.configured?"T":"F") << " .active=" << (con.second.active?"T":"F"); logwrite( function, message.str() ); #endif - if ( con.second.inactive ) continue; // skip controllers flagged as inactive + if (!con.second.configured) continue; // skip controllers not configured if ( con.second.channel == tryme ) { // check to see if it matches a configured channel. dev = con.second.devnum; chan = tryme; @@ -784,7 +790,7 @@ namespace AstroCam { std::string function = "AstroCam::Interface::do_abort"; std::stringstream message; // int this_expbuf = this->get_expbuf(); - for ( const auto &dev : this->devnums ) { + for ( const auto &dev : this->active_devnums ) { this->exposure_pending( dev, false ); for ( int buf=0; buf < NUM_EXPBUF; ++buf ) this->write_pending( buf, dev, false ); } @@ -838,7 +844,7 @@ namespace AstroCam { std::vector pending = this->exposure_pending_list(); message.str(""); message << "ERROR: cannot change binning while exposure is pending for chan"; message << ( pending.size() > 1 ? "s " : " " ); - for ( const auto &dev : pending ) message << this->controller[dev].channel << " "; + for ( const auto &dev : pending ) message << this->controller.at(dev).channel << " "; this->camera.async.enqueue_and_log( "CAMERAD", function, message.str() ); retstring="exposure_in_progress"; return(ERROR); @@ -877,8 +883,10 @@ namespace AstroCam { // This uses the existing image size parameters and the new binning. // The requested overscans are sent here, which can be modified by binning. // - for ( const auto &dev : this->devnums ) { - Controller* pcontroller = &this->controller[dev]; + for ( const auto &dev : this->active_devnums ) { + auto pcontroller = this->get_active_controller(dev); + if (!pcontroller) continue; + // determine which physical axis corresponds to the requested logical axis int physical_axis; if (logical_axis == "spec") { @@ -925,10 +933,10 @@ namespace AstroCam { // return binning for the requested logical axis if (this->numdev>0) { - int dev = this->devnums[0]; - int physical_axis = (logical_axis=="spec") ? this->controller[dev].spec_physical_axis() : - this->controller[dev].spat_physical_axis(); - message.str(""); message << this->controller[dev].info.binning[physical_axis]; + int dev = this->active_devnums[0]; + int physical_axis = (logical_axis=="spec") ? this->controller.at(dev).spec_physical_axis() : + this->controller.at(dev).spat_physical_axis(); + message.str(""); message << this->controller.at(dev).info.binning[physical_axis]; if ( error == NO_ERROR ) retstring = message.str(); } } @@ -957,7 +965,7 @@ namespace AstroCam { * connect to all detected devices. * * If devices_in is specified (and not empty) then it must contain a space-delimited - * list of device numbers to open. A public vector devnums will hold these device + * list of device numbers to open. A public vector connected_devnums will hold these device * numbers. This vector will be updated here to represent only the devices that * are actually connected. * @@ -966,6 +974,8 @@ namespace AstroCam { * user wishes to connect to only the device(s) available then the user must * call with the specific device(s). In other words, it's all (requested) or nothing. * + * This will override the controller active flag; all opened devices become active. + * */ long Interface::do_connect_controller( const std::string devices_in, std::string &retstring ) { std::string function = "AstroCam::Interface::do_connect_controller"; @@ -1027,25 +1037,25 @@ namespace AstroCam { // Look at the requested device(s) to open, which are in the // space-delimited string devices_in. The devices to open - // are stored in a public vector "devnums". + // are stored in a public vector "connected_devnums". // // If no string is given then use vector of configured devices. The configured_devnums // vector contains a list of devices defined in the config file with the // keyword CONTROLLER=( ). // - if ( devices_in.empty() ) this->devnums = this->configured_devnums; + if ( devices_in.empty() ) this->connected_devnums = this->configured_devnums; else { - // Otherwise, tokenize the device list string and build devnums from the tokens + // Otherwise, tokenize the device list string and build connected_devnums from the tokens // - this->devnums.clear(); // empty devnums vector since it's being built here + this->connected_devnums.clear(); // empty connected_devnums vector since it's being built here std::vector tokens; Tokenize(devices_in, tokens, " "); for ( const auto &n : tokens ) { // For each token in the devices_in string, try { int dev = std::stoi( n ); // convert to int - if ( std::find( this->devnums.begin(), this->devnums.end(), dev ) == this->devnums.end() ) { // If it's not already in the vector, - this->devnums.push_back( dev ); // then push into devnums vector. + if ( std::find( this->connected_devnums.begin(), this->connected_devnums.end(), dev ) == this->connected_devnums.end() ) { // If it's not already in the vector, + this->connected_devnums.push_back( dev ); // then push into connected_devnums vector. } } catch (const std::exception &e) { @@ -1062,130 +1072,109 @@ namespace AstroCam { } } - // For each requested dev in devnums, if there is a matching controller in the config file, + // For each requested dev in connected_devnums, if there is a matching controller in the config file, // then get the devname and store it in the controller map. // - for ( const auto &dev : this->devnums ) { + for ( const auto &dev : this->connected_devnums ) { if ( this->controller.find( dev ) != this->controller.end() ) { - this->controller[ dev ].devname = devNames[dev]; + this->controller.at( dev ).devname = devNames[dev]; } } - // The size of devnums at this point is the number of devices that will + // The size of connected_devnums at this point is the number of devices that will // be _requested_ to be opened. This should match the number of opened // devices at the end of this function. // - size_t requested_device_count = this->devnums.size(); + size_t requested_device_count = this->connected_devnums.size(); - // Open only the devices specified by the devnums vector + // Open only the devices specified by the connected_devnums vector // - for ( size_t i = 0; i < this->devnums.size(); ) { - int dev = this->devnums[i]; + for ( size_t i = 0; i < this->connected_devnums.size(); ) { + int dev = this->connected_devnums[i]; auto dev_found = this->controller.find( dev ); if ( dev_found == this->controller.end() ) { message.str(""); message << "ERROR: devnum " << dev << " not found in controller definition. check config file"; logwrite( function, message.str() ); - this->controller[dev].inactive=true; // flag the non-connected controller as inactive + this->controller.at(dev).configured=false; // flag as no longer configured + this->controller.at(dev).active=false; // flag as no longer active this->do_disconnect_controller(dev); retstring="unknown_device"; error = ERROR; break; } - else this->controller[dev].inactive=false; + else this->controller.at(dev).configured=true; try { // Open the PCI device if not already open // (otherwise just reset and test connection) // - if ( ! this->controller[dev].connected ) { - message.str(""); message << "opening " << this->controller[dev].devname; + if ( ! this->controller.at(dev).connected ) { + message.str(""); message << "opening " << this->controller.at(dev).devname; logwrite(function, message.str()); - this->controller[dev].pArcDev->open(dev); + this->controller.at(dev).pArcDev->open(dev); } else { - message.str(""); message << this->controller[dev].devname << " already open"; + message.str(""); message << this->controller.at(dev).devname << " already open"; logwrite(function, message.str()); } // Reset the PCI device // - message.str(""); message << "resetting " << this->controller[dev].devname; + message.str(""); message << "resetting " << this->controller.at(dev).devname; logwrite(function, message.str()); try { - this->controller[dev].pArcDev->reset(); + this->controller.at(dev).pArcDev->reset(); } catch (const std::exception &e) { - message.str(""); message << "ERROR resetting " << this->controller[dev].devname << ": " << e.what(); + message.str(""); message << "ERROR resetting " << this->controller.at(dev).devname << ": " << e.what(); logwrite(function, message.str()); error = ERROR; } // Is Controller Connected? (tested with a TDL command) // - this->controller[dev].connected = this->controller[dev].pArcDev->isControllerConnected(); - message.str(""); message << this->controller[dev].devname << (this->controller[dev].connected ? "" : " not" ) << " connected to ARC controller" - << (this->controller[dev].connected ? " for channel " : "" ) - << (this->controller[dev].connected ? this->controller[dev].channel : "" ); + this->controller.at(dev).connected = this->controller.at(dev).pArcDev->isControllerConnected(); + message.str(""); message << this->controller.at(dev).devname << (this->controller.at(dev).connected ? "" : " not" ) << " connected to ARC controller" + << (this->controller.at(dev).connected ? " for channel " : "" ) + << (this->controller.at(dev).connected ? this->controller.at(dev).channel : "" ); logwrite(function, message.str()); - // If not connected then this should remove it from the devnums list - // - if ( !this->controller[dev].connected ) this->do_disconnect_controller(dev); - -/****** YOU CAN'T DO THIS - // Now that controller is open, update it with the current image size - // that has been stored in the class. Create an arg string in the same - // format as that found in the config file. - // - std::stringstream args; - std::string retstring; - args << dev << " " - << this->controller[dev].detrows << " " - << this->controller[dev].detcols << " " - << this->controller[dev].osrows << " " - << this->controller[dev].oscols << " " - << this->camera_info.binning[_ROW_] << " " - << this->camera_info.binning[_COL_]; - - // If image_size fails then close only this controller, - // which allows operating without this one if needed. - // - if ( this->image_size( args.str(), retstring ) != NO_ERROR ) { // set IMAGE_SIZE here after opening - message.str(""); message << "ERROR setting image size for " << this->controller[dev].devname << ": " << retstring; - this->camera.async.enqueue_and_log( function, message.str() ); - this->controller[dev].inactive=true; // flag the non-connected controller as inactive + // If connected then it is active + if ( this->controller.at(dev).connected ) { + this->controller.at(dev).active=true; + } + // otherwise disconnect, which removes it from the connected_devnums list and clears active + else { this->do_disconnect_controller(dev); - error = ERROR; } - ******/ } catch ( const std::exception &e ) { // arc::gen3::CArcPCI::open and reset may throw exceptions - message.str(""); message << "ERROR opening " << this->controller[dev].devname - << " channel " << this->controller[dev].channel << ": " << e.what(); + message.str(""); message << "ERROR opening " << this->controller.at(dev).devname + << " channel " << this->controller.at(dev).channel << ": " << e.what(); this->camera.async.enqueue_and_log( function, message.str() ); - this->controller[dev].inactive=true; // flag the non-connected controller as inactive this->do_disconnect_controller(dev); retstring="exception"; error = ERROR; } - // A call to do_disconnect_controller() can modify the size of devnums, + // A call to do_disconnect_controller() can modify the size of connected_devnums, // so only if the loop index i is still valid with respect to the current - // size of devnums should it be incremented. + // size of connected_devnums should it be incremented. // - if ( i < devnums.size() ) ++i; + if ( i < connected_devnums.size() ) ++i; } // Log the list of connected devices // message.str(""); message << "connected devices { "; - for (const auto &devcheck : this->devnums) { message << devcheck << " "; } message << "}"; + for (const auto &devcheck : this->connected_devnums) { message << devcheck << " "; } message << "}"; logwrite(function, message.str()); - // check the size of the devnums now, against the size requested + // if the size of the connected_devnums now is not the size requested + // then close them all // - if ( this->devnums.size() != requested_device_count ) { - message.str(""); message << "ERROR: " << this->devnums.size() <<" connected device(s) but " + if ( this->connected_devnums.size() != requested_device_count ) { + message.str(""); message << "ERROR: " << this->connected_devnums.size() <<" connected device(s) but " << requested_device_count << " requested"; logwrite( function, message.str() ); @@ -1201,6 +1190,10 @@ namespace AstroCam { error = ERROR; } + // all connected devnums are active devnums at this stage + // + this->active_devnums = this->connected_devnums; + // Start a thread to monitor the state of things (if not already running) // if ( !this->state_monitor_thread_running.load() ) { @@ -1233,6 +1226,8 @@ namespace AstroCam { * @brief closes the connection to the specified PCI/e device * @return ERROR or NO_ERROR * + * This will override the controller active flag; all closed devices are inactive. + * * This function is overloaded * */ @@ -1245,7 +1240,7 @@ namespace AstroCam { return ERROR; } - // close indicated PCI device and remove dev from devnums + // close indicated PCI device and remove dev from connected_devnums // try { if ( this->controller.at(dev).pArcDev == nullptr ) { @@ -1257,12 +1252,11 @@ namespace AstroCam { logwrite(function, message.str()); this->controller.at(dev).pArcDev->close(); // throws nothing, no error handling this->controller.at(dev).connected=false; - // remove dev from devnums - // - auto it = std::find( this->devnums.begin(), this->devnums.end(), dev ); - if ( it != this->devnums.end() ) { - this->devnums.erase(it); - } + this->controller.at(dev).active=false; + + // remove dev from connected and active devnums + remove_dev(dev, this->connected_devnums); + remove_dev(dev, this->active_devnums); } catch ( std::out_of_range &e ) { message.str(""); message << "dev " << dev << " not found: " << e.what(); @@ -1282,6 +1276,8 @@ namespace AstroCam { * * no error handling. can only fail if the camera is busy. * + * This will override the controller active flag; all closed devices are inactive. + * * This function is overloaded * */ @@ -1302,10 +1298,12 @@ namespace AstroCam { logwrite(function, message.str()); if ( con.second.pArcDev != nullptr ) con.second.pArcDev->close(); // throws nothing con.second.connected=false; + con.second.active=false; } - this->devnums.clear(); // no devices open - this->numdev = 0; // no devices open + this->connected_devnums.clear(); // no devices open + this->active_devnums.clear(); // no devices open + this->numdev = 0; // no devices open return error; } /***** AstroCam::Interface::do_disconnect_controller ************************/ @@ -1322,21 +1320,21 @@ namespace AstroCam { std::string function = "AstroCam::Interface::is_connected"; std::stringstream message; - size_t ndev = this->devnums.size(); /// number of connected devices - size_t nopen=0; /// number of open devices (should be equal to ndev if all are open) + size_t ndev = this->connected_devnums.size(); /// number of connected devices + size_t nopen=0; /// number of open devices (should be equal to ndev if all are open) // look through all connected devices // - for ( const auto &dev : this->devnums ) { + for ( const auto &dev : this->connected_devnums ) { if ( this->controller.find( dev ) != this->controller.end() ) - if ( this->controller[dev].connected ) nopen++; + if ( this->controller.at(dev).connected ) nopen++; #ifdef LOGLEVEL_DEBUG - message.str(""); message << "[DEBUG] " << this->controller[dev].devname << " is " << ( this->controller[dev].connected ? "connected" : "disconnected" ); + message.str(""); message << "[DEBUG] " << this->controller.at(dev).devname << " is " << ( this->controller.at(dev).connected ? "connected" : "disconnected" ); logwrite( function, message.str() ); #endif } - // If all devices in (non-empty) devnums are connected then return true, + // If all devices in (non-empty) connected_devnums are connected then return true, // otherwise return false. // if ( ndev !=0 && ndev == nopen ) { @@ -1368,6 +1366,8 @@ namespace AstroCam { // which will get built up from parse_controller_config() below. // this->configured_devnums.clear(); + this->active_devnums.clear(); + this->connected_devnums.clear(); // loop through the entries in the configuration file, stored in config class // @@ -1542,10 +1542,10 @@ namespace AstroCam { return this->do_native( dev, cmdstr, dontcare ); } else { - // didn't find a dev in args so build vector of all open controllers - std::vector selectdev; - for ( const auto &dev : this->devnums ) { - if ( this->controller[dev].connected ) selectdev.push_back( dev ); + // didn't find a dev in args so build vector of all active controllers + std::vector selectdev; + for ( const auto &dev : this->active_devnums ) { + if ( this->controller.at(dev).connected ) selectdev.push_back( dev ); } // this will send the native command to all controllers in that vector return this->do_native( selectdev, args, retstring ); @@ -1562,11 +1562,12 @@ namespace AstroCam { * @return NO_ERROR on success, ERROR on error * */ - long Interface::do_native(std::vector selectdev, std::string cmdstr) { - // Use the erase-remove idiom to remove disconnected devices from selectdev + long Interface::do_native(std::vector selectdev, std::string cmdstr) { + // Use the erase-remove idiom to remove disconnected/inactive devices from selectdev // selectdev.erase( std::remove_if( selectdev.begin(), selectdev.end(), - [this](uint32_t dev) { return !this->controller[dev].connected; } ), + [this](int dev) { return !this->controller.at(dev).connected || + !this->controller.at(dev).active; } ), selectdev.end() ); std::string retstring; @@ -1585,8 +1586,8 @@ namespace AstroCam { * */ long Interface::do_native( int dev, std::string cmdstr, std::string &retstring ) { - std::vector selectdev; - if ( this->controller[dev].connected ) selectdev.push_back( dev ); + std::vector selectdev; + if ( this->controller.at(dev).active ) selectdev.push_back( dev ); return this->do_native( selectdev, cmdstr, retstring ); } /***** AstroCam::Interface::do_native ***************************************/ @@ -1601,7 +1602,7 @@ namespace AstroCam { * @return NO_ERROR | ERROR | HELP * */ - long Interface::do_native( std::vector selectdev, std::string cmdstr, std::string &retstring ) { + long Interface::do_native( std::vector selectdev, std::string cmdstr, std::string &retstring ) { std::string function = "AstroCam::Interface::do_native"; std::stringstream message; std::vector tokens; @@ -1641,6 +1642,15 @@ namespace AstroCam { return( ERROR ); } + // purge selectdev of any inactive devnums to prevent sending this command + // to an inactive controller + // + for (const auto &dev : selectdev) { + if (!this->controller.at(dev).active) { + remove_dev(dev, selectdev); + } + } + std::vector cmd; // this vector will contain the cmd and any arguments uint32_t c0, c1, c2; @@ -1711,7 +1721,7 @@ namespace AstroCam { { // start local scope for this stuff std::vector threads; // local scope vector stores all of the threads created here for ( const auto &dev : selectdev ) { // spawn a thread for each device in selectdev -// std::thread thr( std::ref(AstroCam::Interface::dothread_native), std::ref(this->controller[dev]), cmd ); +// std::thread thr( std::ref(AstroCam::Interface::dothread_native), std::ref(this->controller.at(dev)), cmd ); std::thread thr( &AstroCam::Interface::dothread_native, std::ref(*this), dev, cmd ); threads.push_back(std::move(thr)); // push the thread into a vector } @@ -1734,7 +1744,7 @@ namespace AstroCam { // std::uint32_t check_retval; try { - check_retval = this->controller[selectdev.at(0)].retval; // save the first one in the controller vector + check_retval = this->controller.at(selectdev.at(0)).retval; // save the first one in the controller vector } catch(std::out_of_range &) { logwrite(function, "ERROR: no device found. Is the controller connected?"); @@ -1743,7 +1753,7 @@ namespace AstroCam { } bool allsame = true; - for ( const auto &dev : selectdev ) { if (this->controller[dev].retval != check_retval) { allsame = false; } } + for ( const auto &dev : selectdev ) { if (this->controller.at(dev).retval != check_retval) { allsame = false; } } // If all the return values are equal then return only one value... // @@ -1756,8 +1766,8 @@ namespace AstroCam { else { std::stringstream rs; for ( const auto &dev : selectdev ) { - this->retval_to_string( this->controller[dev].retval, retstring ); // this sets retstring = to_string( retval ) - rs << std::dec << this->controller[dev].devnum << ":" << retstring << " "; // build up a stringstream of each controller's reply + this->retval_to_string( this->controller.at(dev).retval, retstring ); // this sets retstring = to_string( retval ) + rs << std::dec << this->controller.at(dev).devnum << ":" << retstring << " "; // build up a stringstream of each controller's reply } retstring = rs.str(); // re-use retstring to contain all of the replies } @@ -1769,15 +1779,15 @@ namespace AstroCam { /*** for ( const auto &dev : selectdev ) { // any command that doesn't return DON sets error flag - if ( this->controller[dev].retval != 0x00444F4E ) { + if ( this->controller.at(dev).retval != 0x00444F4E ) { error = ERROR; } // std::string retvalstring; -// this->retval_to_string( this->controller[dev].retval, retvalstring ); -// message.str(""); message << this->controller[dev].devname << " \"" << cmdstr << "\"" +// this->retval_to_string( this->controller.at(dev).retval, retvalstring ); +// message.str(""); message << this->controller.at(dev).devname << " \"" << cmdstr << "\"" // << " returns " << retvalstring -// << " (0x" << std::hex << std::uppercase << this->controller[dev].retval << ")"; +// << " (0x" << std::hex << std::uppercase << this->controller.at(dev).retval << ")"; // logwrite(function, message.str()); } ***/ @@ -2242,12 +2252,12 @@ namespace AstroCam { std::stringstream message; uint32_t command; - std::lock_guard lock(this->controller[dev].pcimtx); + std::lock_guard lock(this->controller.at(dev).pcimtx); ++this->pci_cmd_num; message << "sending command (" << std::dec << this->pci_cmd_num << ") to chan " - << this->controller[dev].channel << " dev " << dev << ":" + << this->controller.at(dev).channel << " dev " << dev << ":" << std::setfill('0') << std::setw(2) << std::hex << std::uppercase; for (const auto &arg : cmd) message << " 0x" << arg; logwrite(function, message.str()); @@ -2259,46 +2269,46 @@ namespace AstroCam { // ARC_API now uses an initialized_list object for the TIM_ID, command, and arguments. // The list object must be instantiated with a fixed size at compile time. // - if (cmd.size() == 1) this->controller[dev].retval = this->controller[dev].pArcDev->command( { TIM_ID, cmd.at(0) } ); + if (cmd.size() == 1) this->controller.at(dev).retval = this->controller.at(dev).pArcDev->command( { TIM_ID, cmd.at(0) } ); else - if (cmd.size() == 2) this->controller[dev].retval = this->controller[dev].pArcDev->command( { TIM_ID, cmd.at(0), cmd.at(1) } ); + if (cmd.size() == 2) this->controller.at(dev).retval = this->controller.at(dev).pArcDev->command( { TIM_ID, cmd.at(0), cmd.at(1) } ); else - if (cmd.size() == 3) this->controller[dev].retval = this->controller[dev].pArcDev->command( { TIM_ID, cmd.at(0), cmd.at(1), cmd.at(2) } ); + if (cmd.size() == 3) this->controller.at(dev).retval = this->controller.at(dev).pArcDev->command( { TIM_ID, cmd.at(0), cmd.at(1), cmd.at(2) } ); else - if (cmd.size() == 4) this->controller[dev].retval = this->controller[dev].pArcDev->command( { TIM_ID, cmd.at(0), cmd.at(1), cmd.at(2), cmd.at(3) } ); + if (cmd.size() == 4) this->controller.at(dev).retval = this->controller.at(dev).pArcDev->command( { TIM_ID, cmd.at(0), cmd.at(1), cmd.at(2), cmd.at(3) } ); else - if (cmd.size() == 5) this->controller[dev].retval = this->controller[dev].pArcDev->command( { TIM_ID, cmd.at(0), cmd.at(1), cmd.at(2), cmd.at(3), cmd.at(4) } ); + if (cmd.size() == 5) this->controller.at(dev).retval = this->controller.at(dev).pArcDev->command( { TIM_ID, cmd.at(0), cmd.at(1), cmd.at(2), cmd.at(3), cmd.at(4) } ); else { message.str(""); message << "ERROR: invalid number of command arguments: " << cmd.size() << " (expecting 1,2,3,4,5)"; logwrite(function, message.str()); - this->controller[dev].retval = 0x455252; + this->controller.at(dev).retval = 0x455252; } } catch(const std::runtime_error &e) { message.str(""); message << "ERROR sending (" << this->pci_cmd_num << ") 0x" << std::setfill('0') << std::setw(2) << std::hex << std::uppercase - << command << " to " << this->controller[dev].devname << ": " << e.what(); + << command << " to " << this->controller.at(dev).devname << ": " << e.what(); logwrite(function, message.str()); - this->controller[dev].retval = 0x455252; + this->controller.at(dev).retval = 0x455252; return; } catch(std::out_of_range &) { // impossible logwrite(function, "ERROR: indexing command argument ("+std::to_string(this->pci_cmd_num)+")"); - this->controller[dev].retval = 0x455252; + this->controller.at(dev).retval = 0x455252; return; } catch(...) { message.str(""); message << "ERROR sending (" << std::dec << this->pci_cmd_num << ") 0x" << std::setfill('0') << std::setw(2) << std::hex << std::uppercase - << command << " to " << this->controller[dev].devname << ": unknown"; + << command << " to " << this->controller.at(dev).devname << ": unknown"; logwrite(function, message.str()); - this->controller[dev].retval = 0x455252; + this->controller.at(dev).retval = 0x455252; return; } std::string retvalstring; - this->retval_to_string( this->controller[dev].retval, retvalstring ); - message.str(""); message << this->controller[dev].devname << std::dec << " (" << this->pci_cmd_num << ")" + this->retval_to_string( this->controller.at(dev).retval, retvalstring ); + message.str(""); message << this->controller.at(dev).devname << std::dec << " (" << this->pci_cmd_num << ")" << " returns " << retvalstring; logwrite( function, message.str() ); @@ -2396,10 +2406,10 @@ namespace AstroCam { logwrite(function, message.str()); return(ERROR); } - for (const auto &dev : this->devnums) { // spawn a thread for each device in devnums + for (const auto &dev : this->connected_devnums) { // spawn a thread for each device in connected_devnums try { - int rows = this->controller[dev].rows; - int cols = this->controller[dev].cols; + int rows = this->controller.at(dev).rows; + int cols = this->controller.at(dev).cols; this->nfpseq = parse_val(tokens.at(1)); // requested nframes is nframes/sequence this->nframes = this->nfpseq * this->nsequences; // number of frames is (frames/sequence) x (sequences) @@ -2460,7 +2470,7 @@ namespace AstroCam { } catch( std::out_of_range & ) { message.str(""); message << "ERROR: unable to find device " << dev << " in list: { "; - for ( const auto &check : this->devnums ) message << check << " "; + for ( const auto &check : this->connected_devnums ) message << check << " "; message << "}"; logwrite( function, message.str() ); return( ERROR ); @@ -2544,16 +2554,16 @@ namespace AstroCam { std::string _start_time; long error; - if (this->devnums.empty()) { - logwrite(function, "ERROR no connected controllers"); + if (this->active_devnums.empty()) { + logwrite(function, "ERROR no active controllers"); return ERROR; } - for (const auto &dev : this->devnums) { + for (const auto &dev : this->active_devnums) { std::string naughtylist; - if (!this->controller[dev].is_imsize_set) { + if (!this->controller.at(dev).is_imsize_set) { if (!naughtylist.empty()) naughtylist += ' '; - naughtylist += this->controller[dev].channel; + naughtylist += this->controller.at(dev).channel; } if (!naughtylist.empty()) { logwrite(function, "ERROR image_size not set for channel(s): "+naughtylist); @@ -2571,7 +2581,7 @@ namespace AstroCam { std::vector pending = this->exposure_pending_list(); message.str(""); message << "ERROR: cannot start new exposure while exposure is pending for chan"; message << ( pending.size() > 1 ? "s " : " " ); - for ( const auto &dev : pending ) message << this->controller[dev].channel << " "; + for ( const auto &dev : pending ) message << this->controller.at(dev).channel << " "; this->camera.async.enqueue_and_log( "CAMERAD", function, message.str() ); return(ERROR); } @@ -2616,7 +2626,7 @@ namespace AstroCam { // check readout type // - for ( const auto &dev : this->devnums ) { + for ( const auto &dev : this->active_devnums ) { if ( this->controller[ dev ].info.readout_name.empty() ) { message.str(""); message << "ERROR: readout undefined"; this->camera.async.enqueue_and_log( "CAMERAD", function, message.str() ); @@ -2654,7 +2664,7 @@ namespace AstroCam { // Each thread gets the exposure buffer number for the current exposure, // and a reference to "this" Interface object. // - for ( const auto &dev : this->devnums ) this->write_pending( this_expbuf, dev, true ); + for ( const auto &dev : this->active_devnums ) this->write_pending( this_expbuf, dev, true ); std::thread( std::ref(AstroCam::Interface::FITS_handler), this_expbuf, std::ref(*this) ).detach(); { @@ -2663,23 +2673,23 @@ namespace AstroCam { // If it IS in frame transfer then only clear the CCD if the cameras are idle. // std::string retstr; - for ( const auto &dev : this->devnums ) { + for ( const auto &dev : this->active_devnums ) { if ( this->is_camera_idle( dev ) ) { error = this->do_native( dev, "CLR", retstr ); // send the clear command here to this dev if ( error != NO_ERROR ) { - message.str(""); message << "ERROR clearing chan " << this->controller[dev].channel << " CCD: " << retstr; + message.str(""); message << "ERROR clearing chan " << this->controller.at(dev).channel << " CCD: " << retstr; logwrite( function, message.str() ); return( error ); } - message.str(""); message << "cleared chan " << this->controller[dev].channel << " CCD"; + message.str(""); message << "cleared chan " << this->controller.at(dev).channel << " CCD"; logwrite( function, message.str() ); } #ifdef LOGLEVEL_DEBUG else { - message.str(""); message << "[DEBUG] chan " << this->controller[dev].channel << " CCD was *not* cleared:" + message.str(""); message << "[DEBUG] chan " << this->controller.at(dev).channel << " CCD was *not* cleared:" << " exposure_pending=" << this->exposure_pending() - << " in_readout=" << this->controller[dev].in_readout - << " in_frametransfer=" << this->controller[dev].in_frametransfer; + << " in_readout=" << this->controller.at(dev).in_readout + << " in_frametransfer=" << this->controller.at(dev).in_frametransfer; logwrite( function, message.str() ); } #endif @@ -2730,22 +2740,22 @@ namespace AstroCam { // and spawn a thread to monitor it, which will provide a notification // when ready for the next exposure. // - for ( const auto &dev : this->devnums ) this->exposure_pending( dev, true ); + for ( const auto &dev : this->active_devnums ) this->exposure_pending( dev, true ); this->state_monitor_condition.notify_all(); std::thread( std::ref(AstroCam::Interface::dothread_monitor_exposure_pending), std::ref(*this) ).detach(); - // prepare the camera info class object for each controller + // prepare the camera info class object for each active controller // - for (const auto &dev : this->devnums) { // spawn a thread for each device in devnums + for (const auto &dev : this->active_devnums) { // spawn a thread for each device in active_devnums try { // Initialize a frame counter for each device. // - this->controller[dev].init_framecount(); + this->controller.at(dev).init_framecount(); // Allocate workspace memory for deinterlacing (each dev has its own workbuf) // - if ( ( error = this->controller[dev].alloc_workbuf( ) ) != NO_ERROR ) { + if ( ( error = this->controller.at(dev).alloc_workbuf( ) ) != NO_ERROR ) { this->camera.async.enqueue_and_log( "CAMERAD", function, "ERROR: allocating memory for deinterlacing" ); return( error ); } @@ -2754,16 +2764,16 @@ namespace AstroCam { // then set the filename for this specific dev // Assemble the FITS filename. // If naming type = "time" then this will use this->fitstime so that must be set first. - // If there are multiple devices in the devnums then force the fitsname to include the dev number + // If there are multiple devices in the active_devnums then force the fitsname to include the dev number // in order to make it unique for each device. // - if ( this->devnums.size() > 1 ) { + if ( this->active_devnums.size() > 1 ) { devstr = std::to_string( dev ); // passing a non-empty devstr will put that in the fitsname } else { devstr = ""; } - if ( ( error = this->camera.get_fitsname( devstr, this->controller[dev].info.fits_name ) ) != NO_ERROR ) { + if ( ( error = this->camera.get_fitsname( devstr, this->controller.at(dev).info.fits_name ) ) != NO_ERROR ) { this->camera.async.enqueue_and_log( "CAMERAD", function, "ERROR: assembling fitsname" ); return( error ); } @@ -2772,15 +2782,15 @@ namespace AstroCam { #ifdef LOGLEVEL_DEBUG message.str(""); message << "[DEBUG] pointers for dev " << dev << ": " - << " pArcDev=" << std::hex << this->controller[dev].pArcDev - << " pCB=" << std::hex << this->controller[dev].pCallback; -// << " pFits=" << std::hex << this->controller[dev].pFits; + << " pArcDev=" << std::hex << this->controller.at(dev).pArcDev + << " pCB=" << std::hex << this->controller.at(dev).pCallback; +// << " pFits=" << std::hex << this->controller.at(dev).pFits; logwrite(function, message.str()); #endif } catch(std::out_of_range &) { message.str(""); message << "ERROR: unable to find device " << dev << " in list: { "; - for (const auto &check : this->devnums) message << check << " "; + for (const auto &check : this->active_devnums) message << check << " "; message << "}"; this->camera.async.enqueue_and_log( "CAMERAD", function, message.str() ); return(ERROR); @@ -2812,7 +2822,7 @@ namespace AstroCam { this->camera_info.systemkeys.add_key("CDELT2A", 0.25*this->camera_info.binspat, "Spatial scale in arcsec/pixel", EXT, "all"); this->camera_info.systemkeys.add_key("CRVAL2A", 0.0, "Reference value in arcsec", EXT, "all"); - for (const auto &dev : this->devnums) { // spawn a thread for each device in devnums + for (const auto &dev : this->active_devnums) { // spawn a thread for each device in active_devnums this->make_image_keywords(dev); @@ -2820,27 +2830,27 @@ namespace AstroCam { // copy the info class from controller[dev] to controller[dev].expinfo[expbuf] // - this->controller[dev].expinfo[this_expbuf] = this->controller[dev].info; + this->controller.at(dev).expinfo[this_expbuf] = this->controller.at(dev).info; // copy the info class from controller[dev] to controller[dev].expinfo[expbuf] // create handy references to the Common::Header objects for expinfo // - auto &_systemkeys = this->controller[dev].expinfo[this_expbuf].systemkeys; - auto &_telemkeys = this->controller[dev].expinfo[this_expbuf].telemkeys; - auto &_userkeys = this->controller[dev].expinfo[this_expbuf].userkeys; + auto &_systemkeys = this->controller.at(dev).expinfo[this_expbuf].systemkeys; + auto &_telemkeys = this->controller.at(dev).expinfo[this_expbuf].telemkeys; + auto &_userkeys = this->controller.at(dev).expinfo[this_expbuf].userkeys; // store BOI in this local _systemkeys so that it's overwritten each exposure // int nboi=0; int lastrowread=0; int stop=0; - for ( const auto &[nskip,nread] : this->controller[dev].info.interest_bands ) { + for ( const auto &[nskip,nread] : this->controller.at(dev).info.interest_bands ) { nboi++; std::string boikey = "BOI"+std::to_string(nboi); lastrowread += nskip; stop = lastrowread+nread; std::string boival = std::to_string(lastrowread)+":"+std::to_string(stop); - _systemkeys.add_key( boikey, boival, "band of interest "+std::to_string(nboi), EXT, this->controller[dev].channel ); + _systemkeys.add_key( boikey, boival, "band of interest "+std::to_string(nboi), EXT, this->controller.at(dev).channel ); lastrowread = stop; } @@ -2861,7 +2871,7 @@ namespace AstroCam { // to reference keywords to be written to all extensions and only the // extension for this channel // - auto channel = this->controller[dev].channel; + auto channel = this->controller.at(dev).channel; std::vector channels = { "all", channel }; // Loop through both "channels" and merge the Header objects from camera_info @@ -2880,10 +2890,10 @@ namespace AstroCam { this->fitsinfo[this_expbuf]->telemkeys.primary() = _telemkeys.primary(); this->fitsinfo[this_expbuf]->userkeys.primary() = _userkeys.primary(); - this->controller[dev].expinfo[this_expbuf].fits_name="not_needed"; + this->controller.at(dev).expinfo[this_expbuf].fits_name="not_needed"; std::string hash; - md5_file( this->controller[dev].firmware, hash ); // compute the md5 hash + md5_file( this->controller.at(dev).firmware, hash ); // compute the md5 hash // erase the per-exposure keyword databases. // @@ -2905,13 +2915,13 @@ namespace AstroCam { // std::thread( std::ref(AstroCam::Interface::dothread_read), std::ref(this->camera), - std::ref(this->controller[dev]), + std::ref(this->controller.at(dev)), this_expbuf ).detach(); } catch(std::out_of_range &) { message.str(""); message << "ERROR: unable to find device " << dev << " in list: { "; - for (const auto &check : this->devnums) message << check << " "; + for (const auto &check : this->active_devnums) message << check << " "; message << "}"; this->camera.async.enqueue_and_log( "CAMERAD", function, message.str() ); return(ERROR); @@ -2930,36 +2940,36 @@ namespace AstroCam { logwrite( function, "[DEBUG] expose is done now!" ); - for (const auto &dev : this->devnums) { + for (const auto &dev : this->active_devnums) { message.str(""); message << std::dec - << "** dev=" << dev << " type_set=" << this->controller[dev].info.type_set << " frame_type=" << this->controller[dev].info.frame_type - << " detector_pixels[]=" << this->controller[dev].info.detector_pixels[0] << " " << this->controller[dev].info.detector_pixels[1] - << " section_size=" << this->controller[dev].info.section_size << " image_memory=" << this->controller[dev].info.image_memory - << " readout_name=" << this->controller[dev].info.readout_name - << " readout_name2=" << this->controller[dev].expinfo[this_expbuf].readout_name - << " readout_type=" << this->controller[dev].info.readout_type - << " axes[]=" << this->controller[dev].info.axes[0] << " " << this->controller[dev].info.axes[1] << " " << this->controller[dev].info.axes[2] - << " cubedepth=" << this->controller[dev].info.cubedepth << " fitscubed=" << this->controller[dev].info.fitscubed - << " phys binning=" << this->controller[dev].info.binning[0] << " " << this->controller[dev].info.binning[1] - << " axis_pixels[]=" << this->controller[dev].info.axis_pixels[0] << " " << this->controller[dev].info.axis_pixels[1] - << " ismex=" << this->controller[dev].info.ismex << " extension=" << this->controller[dev].info.extension; + << "** dev=" << dev << " type_set=" << this->controller.at(dev).info.type_set << " frame_type=" << this->controller.at(dev).info.frame_type + << " detector_pixels[]=" << this->controller.at(dev).info.detector_pixels[0] << " " << this->controller.at(dev).info.detector_pixels[1] + << " section_size=" << this->controller.at(dev).info.section_size << " image_memory=" << this->controller.at(dev).info.image_memory + << " readout_name=" << this->controller.at(dev).info.readout_name + << " readout_name2=" << this->controller.at(dev).expinfo[this_expbuf].readout_name + << " readout_type=" << this->controller.at(dev).info.readout_type + << " axes[]=" << this->controller.at(dev).info.axes[0] << " " << this->controller.at(dev).info.axes[1] << " " << this->controller.at(dev).info.axes[2] + << " cubedepth=" << this->controller.at(dev).info.cubedepth << " fitscubed=" << this->controller.at(dev).info.fitscubed + << " phys binning=" << this->controller.at(dev).info.binning[0] << " " << this->controller.at(dev).info.binning[1] + << " axis_pixels[]=" << this->controller.at(dev).info.axis_pixels[0] << " " << this->controller.at(dev).info.axis_pixels[1] + << " ismex=" << this->controller.at(dev).info.ismex << " extension=" << this->controller.at(dev).info.extension; logwrite( function, message.str() ); } - for (const auto &dev : this->devnums) { + for (const auto &dev : this->active_devnums) { for ( int ii=0; iicontroller[dev].expinfo.at(ii).detector_pixels[1] - << " section_size=" << this->controller[dev].expinfo.at(ii).section_size << " image_memory=" << this->controller[dev].expinfo.at(ii).image_memory - << " readout_name=" << this->controller[dev].expinfo.at(ii).readout_name << " readout_type=" << this->controller[dev].expinfo.at(ii).readout_type - << " axes[]=" << this->controller[dev].expinfo.at(ii).axes[0] << " " << this->controller[dev].expinfo.at(ii).axes[1] << " " << this->controller[dev].expinfo.at(ii).axes[2] - << " cubedepth=" << this->controller[dev].expinfo.at(ii).cubedepth << " fitscubed=" << this->controller[dev].expinfo.at(ii).fitscubed - << " phys binning=" << this->controller[dev].expinfo.at(ii).binning[0] << " " << this->controller[dev].expinfo.at(ii).binning[1] - << " axis_pixels[]=" << this->controller[dev].expinfo.at(ii).axis_pixels[0] << " " << this->controller[dev].expinfo.at(ii).axis_pixels[1] - << " ismex=" << this->controller[dev].expinfo.at(ii).ismex << " extension=" << this->controller[dev].expinfo.at(ii).extension; + << "** dev=" << dev << " expbuf=" << ii << " type_set=" << this->controller.at(dev).expinfo.at(ii).type_set << " frame_type=" << this->controller.at(dev).expinfo.at(ii).frame_type + << " detector_pixels[]=" << this->controller.at(dev).expinfo.at(ii).detector_pixels[0] << " " << this->controller.at(dev).expinfo.at(ii).detector_pixels[1] + << " section_size=" << this->controller.at(dev).expinfo.at(ii).section_size << " image_memory=" << this->controller.at(dev).expinfo.at(ii).image_memory + << " readout_name=" << this->controller.at(dev).expinfo.at(ii).readout_name << " readout_type=" << this->controller.at(dev).expinfo.at(ii).readout_type + << " axes[]=" << this->controller.at(dev).expinfo.at(ii).axes[0] << " " << this->controller.at(dev).expinfo.at(ii).axes[1] << " " << this->controller.at(dev).expinfo.at(ii).axes[2] + << " cubedepth=" << this->controller.at(dev).expinfo.at(ii).cubedepth << " fitscubed=" << this->controller.at(dev).expinfo.at(ii).fitscubed + << " phys binning=" << this->controller.at(dev).expinfo.at(ii).binning[0] << " " << this->controller.at(dev).expinfo.at(ii).binning[1] + << " axis_pixels[]=" << this->controller.at(dev).expinfo.at(ii).axis_pixels[0] << " " << this->controller.at(dev).expinfo.at(ii).axis_pixels[1] + << " ismex=" << this->controller.at(dev).expinfo.at(ii).ismex << " extension=" << this->controller.at(dev).expinfo.at(ii).extension; logwrite( function, message.str() ); } } @@ -3357,7 +3367,7 @@ namespace AstroCam { // to load each controller with the specified file. // for ( const auto &con : this->controller ) { - if ( con.second.inactive ) continue; // skip controllers flagged as inactive + if (!con.second.configured) continue; // skip controllers not configured // But only use it if the device is open // if ( con.second.connected ) { @@ -3399,7 +3409,7 @@ namespace AstroCam { std::string function = "AstroCam::Interface::do_load_firmware"; std::stringstream message; std::vector tokens; - std::vector selectdev; + std::vector selectdev; struct stat st; long error = ERROR; @@ -3441,10 +3451,10 @@ namespace AstroCam { } // If there's only one token then it's the lodfile and load - // into all controllers in the devnums. + // into all controllers in the active_devnums. // if (tokens.size() == 1) { - for (const auto &dev : this->devnums) { + for (const auto &dev : this->active_devnums) { selectdev.push_back( dev ); // build selectdev vector from all connected controllers } } @@ -3455,7 +3465,7 @@ namespace AstroCam { // if (tokens.size() > 1) { for (uint32_t n = 0; n < (tokens.size()-1); n++) { // tokens.size() - 1 because the last token must be the filename - selectdev.push_back( (uint32_t)parse_val( tokens.at(n) ) ); + selectdev.push_back( (int)parse_val( tokens.at(n) ) ); } timlodfile = tokens.at( tokens.size() - 1 ); // the last token must be the filename } @@ -3474,8 +3484,8 @@ namespace AstroCam { for (const auto &dev : selectdev) { // spawn a thread for each device in the selectdev list if ( firstdev == -1 ) firstdev = dev; // save the first device from the list of connected controllers try { - if ( this->controller[dev].connected ) { // but only if connected - std::thread thr( std::ref(AstroCam::Interface::dothread_load), std::ref(this->controller[dev]), timlodfile ); + if ( this->controller.at(dev).connected ) { // but only if connected + std::thread thr( std::ref(AstroCam::Interface::dothread_load), std::ref(this->controller.at(dev)), timlodfile ); threads.push_back ( std::move(thr) ); // push the thread into the local vector } } @@ -3517,7 +3527,7 @@ namespace AstroCam { check_retval = this->controller[firstdev].retval; // save the first one in the controller vector bool allsame = true; - for ( const auto &dev : selectdev ) { if ( this->controller[dev].retval != check_retval ) { allsame = false; } } + for ( const auto &dev : selectdev ) { if ( this->controller.at(dev).retval != check_retval ) { allsame = false; } } // If all the return values are equal then report only NO_ERROR (if "DON") or ERROR (anything else) // @@ -3534,8 +3544,8 @@ namespace AstroCam { std::stringstream rss; std::string rs; for (const auto &dev : selectdev) { - this->retval_to_string( this->controller[dev].retval, rs ); // convert the retval to string (DON, ERR, etc.) - rss << this->controller[dev].devnum << ":" << rs << " "; + this->retval_to_string( this->controller.at(dev).retval, rs ); // convert the retval to string (DON, ERR, etc.) + rss << this->controller.at(dev).devnum << ":" << rs << " "; } retstring = rss.str(); error = ERROR; @@ -3545,8 +3555,8 @@ namespace AstroCam { /*** logwrite( function, "NOTICE: firmware loaded" ); for ( const auto &dev : selectdev ) { - for ( auto it = this->controller[dev].extkeys.keydb.begin(); - it != this->controller[dev].extkeys.keydb.end(); it++ ) { + for ( auto it = this->controller.at(dev).extkeys.keydb.begin(); + it != this->controller.at(dev).extkeys.keydb.end(); it++ ) { message.str(""); message << "NOTICE: dev=" << dev << "key=" << it->second.keyword << " val=" << it->second.keyvalue; logwrite( function, message.str() ); } @@ -3554,7 +3564,7 @@ for ( const auto &dev : selectdev ) { ***/ for (const auto &dev: selectdev) { - std::string init=this->controller[dev].channel+" init"; + std::string init=this->controller.at(dev).channel+" init"; std::string retstring; this->image_size(init, retstring); } @@ -3661,7 +3671,7 @@ for ( const auto &dev : selectdev ) { retstring.append( " Specify from { " ); message.str(""); for ( const auto &con : this->controller ) { - if ( con.second.inactive ) continue; // skip controllers flagged as inactive + if (!con.second.configured) continue; // skip controllers not configured message << con.second.channel << " "; } message << "}\n"; @@ -3669,7 +3679,7 @@ for ( const auto &dev : selectdev ) { retstring.append( " or from { " ); message.str(""); for ( const auto &con : this->controller ) { - if ( con.second.inactive ) continue; // skip controllers flagged as inactive + if (!con.second.configured) continue; // skip controllers not configured message << con.second.devnum << " "; } message << "}\n"; @@ -3791,7 +3801,7 @@ for ( const auto &dev : selectdev ) { retstring.append( " Specify from { " ); message.str(""); for ( const auto &con : this->controller ) { - if ( con.second.inactive ) continue; // skip controllers flagged as inactive + if (!con.second.configured) continue; // skip controllers not configured message << con.second.channel << " "; } message << "}\n"; @@ -3799,7 +3809,7 @@ for ( const auto &dev : selectdev ) { retstring.append( " or from { " ); message.str(""); for ( const auto &con : this->controller ) { - if ( con.second.inactive ) continue; // skip controllers flagged as inactive + if (!con.second.configured) continue; // skip controllers not configured message << con.second.devnum << " "; } message << "}\n"; @@ -3872,7 +3882,7 @@ for ( const auto &dev : selectdev ) { // In any case, set or not, get the current type // - retstring = this->controller[dev].info.readout_name; + retstring = this->controller.at(dev).info.readout_name; return( NO_ERROR ); } @@ -3911,7 +3921,7 @@ for ( const auto &dev : selectdev ) { retstring.append( " Specify from { " ); message.str(""); for ( const auto &con : this->controller ) { - if ( con.second.inactive ) continue; // skip controllers flagged as inactive + if (!con.second.configured) continue; // skip controllers not configured message << con.second.channel << " "; } message << "}\n"; @@ -3919,7 +3929,7 @@ for ( const auto &dev : selectdev ) { retstring.append( " or from { " ); message.str(""); for ( const auto &con : this->controller ) { - if ( con.second.inactive ) continue; // skip controllers flagged as inactive + if (!con.second.configured) continue; // skip controllers not configured message << con.second.devnum << " "; } message << "}\n"; @@ -3957,13 +3967,10 @@ for ( const auto &dev : selectdev ) { std::string chan; if ( this->extract_dev_chan( args, dev, chan, retstring ) != NO_ERROR ) return ERROR; - Controller* pcontroller = &this->controller[dev]; + Controller* pcontroller = this->get_active_controller(dev); - // don't continue if that controller is not connected now - // - if ( !pcontroller->connected ) { - logwrite( function, "ERROR controller channel "+chan+" not connected" ); - retstring="not_connected"; + if (!pcontroller) { + logwrite(function, "ERROR: controller not available for channel "+chan); return ERROR; } @@ -4168,11 +4175,11 @@ for ( const auto &dev : selectdev ) { // and fpbcount to index the frameinfo STL map on that devnum, // and assign the pointer to that buffer to a local variable. // - void* imbuf = this->controller[devnum].frameinfo[fpbcount].buf; + void* imbuf = this->controller.at(devnum).frameinfo[fpbcount].buf; - message << this->controller[devnum].devname << " received exposure " - << this->controller[devnum].frameinfo[fpbcount].framenum << " into image buffer " - << std::hex << this->controller[devnum].frameinfo[fpbcount].buf; + message << this->controller.at(devnum).devname << " received exposure " + << this->controller.at(devnum).frameinfo[fpbcount].framenum << " into image buffer " + << std::hex << this->controller.at(devnum).frameinfo[fpbcount].buf; logwrite(function, message.str()); // Call the class' deinterlace and write functions. @@ -4182,12 +4189,12 @@ for ( const auto &dev : selectdev ) { // that buffer is already known. // try { - switch (this->controller[devnum].info.datatype) { + switch (this->controller.at(devnum).info.datatype) { case USHORT_IMG: { - this->controller[devnum].deinterlace( expbuf, (uint16_t *)imbuf ); -message.str(""); message << this->controller[devnum].devname << " exposure buffer " << expbuf << " deinterlaced " << std::hex << imbuf; + this->controller.at(devnum).deinterlace( expbuf, (uint16_t *)imbuf ); +message.str(""); message << this->controller.at(devnum).devname << " exposure buffer " << expbuf << " deinterlaced " << std::hex << imbuf; logwrite(function, message.str()); -message.str(""); message << "about to write section size " << this->controller[devnum].expinfo[expbuf].section_size ; // << " to file \"" << this->pFits[expbuf]->fits_name << "\""; +message.str(""); message << "about to write section size " << this->controller.at(devnum).expinfo[expbuf].section_size ; // << " to file \"" << this->pFits[expbuf]->fits_name << "\""; logwrite(function, message.str()); // Call write_image(), @@ -4198,27 +4205,27 @@ logwrite(function, message.str()); this->pFits[ expbuf ]->extension++; -message.str(""); message << this->controller[devnum].devname << " exposure buffer " << expbuf << " wrote " << std::hex << this->controller[devnum].workbuf; +message.str(""); message << this->controller.at(devnum).devname << " exposure buffer " << expbuf << " wrote " << std::hex << this->controller.at(devnum).workbuf; logwrite(function, message.str()); -// error = this->controller[devnum].write( ); 10/30/23 BOB -- the write is above. .write() called ->write_image(), skip that extra function +// error = this->controller.at(devnum).write( ); 10/30/23 BOB -- the write is above. .write() called ->write_image(), skip that extra function break; } /******* case SHORT_IMG: { - this->controller[devnum].deinterlace( expbuf, (int16_t *)imbuf ); - error = this->controller[devnum].write( ); + this->controller.at(devnum).deinterlace( expbuf, (int16_t *)imbuf ); + error = this->controller.at(devnum).write( ); break; } case FLOAT_IMG: { - this->controller[devnum].deinterlace( expbuf, (uint32_t *)imbuf ); - error = this->controller[devnum].write( ); + this->controller.at(devnum).deinterlace( expbuf, (uint32_t *)imbuf ); + error = this->controller.at(devnum).write( ); break; } ********/ default: message.str(""); - message << "ERROR: unknown datatype: " << this->controller[devnum].info.datatype; + message << "ERROR: unknown datatype: " << this->controller.at(devnum).info.datatype; logwrite(function, message.str()); error = ERROR; break; @@ -4226,16 +4233,16 @@ logwrite(function, message.str()); // A frame has been written for this device, // so increment the framecounter for devnum. // - if (error == NO_ERROR) this->controller[devnum].increment_framecount(); + if (error == NO_ERROR) this->controller.at(devnum).increment_framecount(); #ifdef LOGLEVEL_DEBUG message.str(""); message << "[DEBUG] framecount(" << devnum << ")=" - << this->controller[devnum].get_framecount() << " written"; + << this->controller.at(devnum).get_framecount() << " written"; logwrite( function, message.str() ); #endif } catch (std::out_of_range &) { message.str(""); message << "ERROR: unable to find device " << devnum << " in list: { "; - for (const auto &check : this->devnums) message << check << " "; + for (const auto &check : this->active_devnums) message << check << " "; message << "}"; logwrite(function, message.str()); error = ERROR; @@ -4245,9 +4252,9 @@ logwrite(function, message.str()); message.str(""); message << "[DEBUG] completed " << (error != NO_ERROR ? "with error. " : "ok. ") << "devnum=" << devnum << " " << "fpbcount=" << fpbcount << " " - << this->controller[devnum].devname << " received exposure " - << this->controller[devnum].frameinfo[fpbcount].framenum << " into buffer " - << std::hex << std::uppercase << this->controller[devnum].frameinfo[fpbcount].buf; + << this->controller.at(devnum).devname << " received exposure " + << this->controller.at(devnum).frameinfo[fpbcount].framenum << " into buffer " + << std::hex << std::uppercase << this->controller.at(devnum).frameinfo[fpbcount].buf; logwrite(function, message.str()); #endif return( error ); @@ -4578,16 +4585,16 @@ logwrite(function, message.str()); // Set shutterenable the same for all devices // - if ( error==NO_ERROR && this->camera.ext_shutter ) for ( const auto &dev : this->devnums ) { - this->controller[dev].info.shutterenable = this->camera.shutter.is_enabled; + if ( error==NO_ERROR && this->camera.ext_shutter ) for ( const auto &dev : this->active_devnums ) { + this->controller.at(dev).info.shutterenable = this->camera.shutter.is_enabled; } } // For external shutter (i.e. triggered by Leach controller) // read the shutterenable state back from the controller class. // - if ( this->camera.ext_shutter ) for ( const auto &dev : this->devnums ) { - this->camera.shutter.is_enabled = this->controller[dev].info.shutterenable; + if ( this->camera.ext_shutter ) for ( const auto &dev : this->active_devnums ) { + this->camera.shutter.is_enabled = this->controller.at(dev).info.shutterenable; break; // just need one since they're all the same } // otherwise shutterenable state is whatever is in the camera_info class @@ -4652,7 +4659,7 @@ logwrite(function, message.str()); retstring.append( " Specify from { " ); message.str(""); for ( const auto &con : this->controller ) { - if ( con.second.inactive ) continue; // skip controllers flagged as inactive + if (!con.second.configured) continue; // skip controllers not configured message << con.second.channel << " "; } message << "}\n"; @@ -4660,7 +4667,7 @@ logwrite(function, message.str()); retstring.append( " or from { " ); message.str(""); for ( const auto &con : this->controller ) { - if ( con.second.inactive ) continue; // skip controllers flagged as inactive + if (!con.second.configured) continue; // skip controllers not configured message << con.second.devnum << " "; } message << "}\n"; @@ -4673,8 +4680,8 @@ logwrite(function, message.str()); // if (args=="all") { bool all_true = true; - for ( const auto &dev : this->devnums ) { - if ( !this->controller[dev].have_ft ) { + for ( const auto &dev : this->active_devnums ) { + if ( !this->controller.at(dev).have_ft ) { all_true=false; break; } @@ -4705,14 +4712,14 @@ logwrite(function, message.str()); // If a state was provided then set it // if ( ! retstring.empty() ) { - this->controller[dev].have_ft = ( retstring == "yes" ? true : false ); + this->controller.at(dev).have_ft = ( retstring == "yes" ? true : false ); // add keyword to the extension for this channel -//TCB this->controller[dev].info.systemkeys.add_key( "FT", this->controller[dev].have_ft, "frame transfer used", EXT, chan ); +//TCB this->controller.at(dev).info.systemkeys.add_key( "FT", this->controller.at(dev).have_ft, "frame transfer used", EXT, chan ); } // In any case, return the current state // - retstring = ( this->controller[dev].have_ft ? "yes" : "no" ); + retstring = ( this->controller.at(dev).have_ft ? "yes" : "no" ); return( NO_ERROR ); } @@ -4751,7 +4758,7 @@ logwrite(function, message.str()); retstring.append( " Specify from { " ); message.str(""); for ( const auto &con : this->controller ) { - if ( con.second.inactive ) continue; // skip controllers flagged as inactive + if (!con.second.configured) continue; // skip controllers not configured message << con.second.channel << " "; } message << "}\n"; @@ -4759,7 +4766,7 @@ logwrite(function, message.str()); retstring.append( " or from { " ); message.str(""); for ( const auto &con : this->controller ) { - if ( con.second.inactive ) continue; // skip controllers flagged as inactive + if (!con.second.configured) continue; // skip controllers not configured message << con.second.devnum << " "; } message << "}\n"; @@ -4789,7 +4796,12 @@ logwrite(function, message.str()); std::vector tokens; Tokenize( retstring, tokens, " " ); - Controller* pcontroller = &this->controller[dev]; + Controller* pcontroller = this->get_active_controller(dev); + + if (!pcontroller) { + logwrite(function, "ERROR: controller not available for channel "+chan); + return ERROR; + } int spat=-1, spec=-1, osspat=-1, osspec=-1, binspat=-1, binspec=-1; // start by loading the values in the class @@ -4887,7 +4899,7 @@ logwrite(function, message.str()); pcontroller->skipcols = cols % bincols; pcontroller->skiprows = rows % binrows; -// message.str(""); message << "[DEBUG] skipcols=" << this->controller[dev].skipcols << " skiprows=" << this->controller[dev].skiprows; +// message.str(""); message << "[DEBUG] skipcols=" << this->controller.at(dev).skipcols << " skiprows=" << this->controller.at(dev).skiprows; // logwrite( function, message.str() ); cols -= pcontroller->skipcols; @@ -4923,8 +4935,8 @@ logwrite(function, message.str()); // message.str(""); // message << "[DEBUG] new binned values before set_axes() to re-calculate:" -// << " detector_pixels[" << _COL_ << "]=" << this->controller[dev].info.detector_pixels[_COL_] -// << " detector_pixels[" << _ROW_ << "]=" << this->controller[dev].info.detector_pixels[_ROW_]; +// << " detector_pixels[" << _COL_ << "]=" << this->controller.at(dev).info.detector_pixels[_COL_] +// << " detector_pixels[" << _ROW_ << "]=" << this->controller.at(dev).info.detector_pixels[_ROW_]; // logwrite(function, message.str()); // *** This is where the binned-image dimensions are re-calculated *** @@ -5068,7 +5080,7 @@ logwrite(function, message.str()); retstring.append( " Specify from { " ); message.str(""); for ( const auto &con : this->controller ) { - if ( con.second.inactive ) continue; // skip controllers flagged as inactive + if (!con.second.configured) continue; // skip controllers not configured message << con.second.channel << " "; } message << "}\n"; @@ -5076,7 +5088,7 @@ logwrite(function, message.str()); retstring.append( " or from { " ); message.str(""); for ( const auto &con : this->controller ) { - if ( con.second.inactive ) continue; // skip controllers flagged as inactive + if (!con.second.configured) continue; // skip controllers not configured message << con.second.devnum << " "; } message << "}\n"; @@ -5125,8 +5137,8 @@ logwrite(function, message.str()); return( ERROR ); } -// message.str(""); message << "[DEBUG] " << this->controller[dev].devname -// << " chan " << this->controller[dev].channel << " rows:" << setrows << " cols:" << setcols; +// message.str(""); message << "[DEBUG] " << this->controller.at(dev).devname +// << " chan " << this->controller.at(dev).channel << " rows:" << setrows << " cols:" << setcols; // logwrite( function, message.str() ); // Write the geometry to the selected controllers @@ -5157,13 +5169,13 @@ logwrite(function, message.str()); // cmd.str(""); cmd << "RDM 0x400001 "; if ( this->do_native( dev, cmd.str(), getcols ) != NO_ERROR ) return ERROR; - this->controller[dev].cols = (uint32_t)parse_val( getcols.substr( getcols.find(":")+1 ) ); + this->controller.at(dev).cols = (uint32_t)parse_val( getcols.substr( getcols.find(":")+1 ) ); cmd.str(""); cmd << "RDM 0x400002 "; if ( this->do_native( dev, cmd.str(), getrows ) != NO_ERROR ) return ERROR; - this->controller[dev].rows = (uint32_t)parse_val( getrows.substr( getrows.find(":")+1 ) ); + this->controller.at(dev).rows = (uint32_t)parse_val( getrows.substr( getrows.find(":")+1 ) ); - rs << this->controller[dev].rows << " " << this->controller[dev].cols; + rs << this->controller.at(dev).rows << " " << this->controller.at(dev).cols; retstring = rs.str(); // Form the return string from the read-back rows cols @@ -5248,36 +5260,36 @@ logwrite(function, message.str()); // // When useframes is false, fpbcount=0, fcount=0, framenum=0 // - if ( ! server.controller[devnum].have_ft ) { + if ( ! server.controller.at(devnum).have_ft ) { server.exposure_pending( devnum, false ); // this also does the notify server.state_monitor_condition.notify_all(); #ifdef LOGLEVEL_DEBUG - message.str(""); message << "[DEBUG] dev " << devnum << " chan " << server.controller[devnum].channel << " exposure_pending=false"; + message.str(""); message << "[DEBUG] dev " << devnum << " chan " << server.controller.at(devnum).channel << " exposure_pending=false"; server.camera.async.enqueue_and_log( "CAMERAD", function, message.str() ); #endif } - server.controller[devnum].in_readout = false; + server.controller.at(devnum).in_readout = false; server.state_monitor_condition.notify_all(); #ifdef LOGLEVEL_DEBUG - message.str(""); message << "[DEBUG] dev " << devnum << " chan " << server.controller[devnum].channel << " in_readout=false"; + message.str(""); message << "[DEBUG] dev " << devnum << " chan " << server.controller.at(devnum).channel << " in_readout=false"; server.camera.async.enqueue_and_log( "CAMERAD", function, message.str() ); #endif - server.controller[devnum].frameinfo[fpbcount].tid = fpbcount; // create this index in the .frameinfo[] map - server.controller[devnum].frameinfo[fpbcount].buf = buffer; + server.controller.at(devnum).frameinfo[fpbcount].tid = fpbcount; // create this index in the .frameinfo[] map + server.controller.at(devnum).frameinfo[fpbcount].buf = buffer; /*** - if ( server.controller[devnum].frameinfo.count( fpbcount ) == 0 ) { // searches .frameinfo[] map for an index of fpbcount (none) - server.controller[devnum].frameinfo[ fpbcount ].tid = fpbcount; // create this index in the .frameinfo[] map - server.controller[devnum].frameinfo[ fpbcount ].buf = buffer; + if ( server.controller.at(devnum).frameinfo.count( fpbcount ) == 0 ) { // searches .frameinfo[] map for an index of fpbcount (none) + server.controller.at(devnum).frameinfo[ fpbcount ].tid = fpbcount; // create this index in the .frameinfo[] map + server.controller.at(devnum).frameinfo[ fpbcount ].buf = buffer; // If useframes is false then set framenum=0 because it doesn't mean anything, // otherwise set it to the fcount received from the API. // - server.controller[devnum].frameinfo[ fpbcount ].framenum = server.useframes ? fcount : 0; + server.controller.at(devnum).frameinfo[ fpbcount ].framenum = server.useframes ? fcount : 0; } else { // already have this fpbcount in .frameinfo[] map message.str(""); message << "ERROR: frame buffer overrun! Try allocating a larger buffer." - << " chan " << server.controller[devnum].channel; + << " chan " << server.controller.at(devnum).channel; logwrite( function, message.str() ); server.frameinfo_mutex.unlock(); return; @@ -5293,7 +5305,7 @@ logwrite(function, message.str()); double start_time = get_clock_time(); do { int this_frame = fcount; // the current frame - int last_frame = server.controller[devnum].get_framecount(); // the last frame that has been written by this device + int last_frame = server.controller.at(devnum).get_framecount(); // the last frame that has been written by this device int next_frame = last_frame + 1; // the next frame in line if (this_frame != next_frame) { // if the current frame is NOT the next in line then keep waiting usleep(5); @@ -5323,7 +5335,7 @@ logwrite(function, message.str()); message.str(""); message << "[DEBUG] calling server.write_frame for devnum=" << devnum << " fpbcount=" << fpbcount; logwrite(function, message.str()); #endif - error = server.write_frame( expbuf, devnum, server.controller[devnum].channel, fpbcount ); + error = server.write_frame( expbuf, devnum, server.controller.at(devnum).channel, fpbcount ); } else { logwrite(function, "aborted!"); @@ -5338,10 +5350,10 @@ logwrite(function, message.str()); // Erase it from the STL map so it's not seen again. // server.frameinfo_mutex.lock(); // protect access to frameinfo structure -// server.controller[devnum].frameinfo.erase( fpbcount ); +// server.controller.at(devnum).frameinfo.erase( fpbcount ); /*** 10/30/23 BOB - server.controller[devnum].close_file( server.camera.writekeys_when ); + server.controller.at(devnum).close_file( server.camera.writekeys_when ); ***/ server.frameinfo_mutex.unlock(); @@ -5404,7 +5416,7 @@ logwrite(function, message.str()); message.str(""); message << "NOTICE:exposure buffer " << expbuf << " waiting for frames from "; std::vector pending = interface.writes_pending[ expbuf ]; - for ( const auto &dev : pending ) message << interface.controller[dev].channel << " "; + for ( const auto &dev : pending ) message << interface.controller.at(dev).channel << " "; logwrite( function, message.str() ); // wait() will repeatedly call this lambda function before actually entering @@ -5430,6 +5442,163 @@ logwrite(function, message.str()); /***** AstroCam::Interface::FITS_handler ************************************/ + /***** AstroCam::Interface::camera_active_state *****************************/ + /** + * @brief set/get camera active state + * + */ + long Interface::camera_active_state(const std::string &args, std::string &retstring, + AstroCam::ActiveState cmd) { + const std::string function("AstroCam::Interface::camera_active_state"); + std::string chan; + std::istringstream iss(args); + + // get channel name from args + // + if (!(iss >> chan)) { + logwrite(function, "ERROR parsing args. expected "); + retstring="bad_argument"; + return ERROR; + } + + // get device number for that channel + // + int dev; + try { + dev = devnum_from_chan(chan); + } + catch(const std::exception &e) { + logwrite(function, "ERROR: "+std::string(e.what())); + retstring="bad_channel"; + return ERROR; + } + + // get pointer to the Controller object for this device + // it only needs to exist and be connected + // + auto pcontroller = this->get_controller(dev); + + if (!pcontroller) { + logwrite(function, "ERROR: channel "+chan+" not configured"); + retstring="missing_device"; + return ERROR; + } + if (!pcontroller->configured || !pcontroller->connected) { + logwrite(function, "ERROR: channel "+chan+" not connected"); + retstring="not_connected"; + return ERROR; + } + + long error = NO_ERROR; + + // set or get active state as specified by cmd + // + switch (cmd) { + + // first set active flag, then turn on power + case AstroCam::ActiveState::Activate: + pcontroller->active = true; + // add this devnum to the active_devnums list + add_dev(dev, this->active_devnums); + error=this->do_native( dev, std::string("PON"), retstring ); + break; + + // first turn off power, then clear active flag + case AstroCam::ActiveState::DeActivate: + if ( (error=this->do_native(dev, std::string("POF"), retstring))==NO_ERROR ) { + pcontroller->active = false; + // remove this devnum from the active_devnums list + remove_dev(dev, this->active_devnums); + } + break; + + // do nothing + case AstroCam::ActiveState::Query: + default: + break; + } + + retstring = pcontroller->active ? "activated" : "deactivated"; + + return error; + } + /***** AstroCam::Interface::camera_active_state *****************************/ + + + /***** AstroCam::Interface::get_controller **********************************/ + /** + * @brief helper function returns pointer to element of Controller map + * @details This will return a pointer if the requested device has been + * configured. + * @param[in] dev integer device number for map indexing + * @return pointer to Controller | nullptr + * + */ + Interface::Controller* Interface::get_controller(const int dev) { + const std::string function("AstroCam::Interface::get_controller"); + std::ostringstream oss; + + auto it = this->controller.find(dev); + + if (it==this->controller.end()) { + oss << "controller for dev " << dev << " not found"; + logwrite(function, oss.str()); + return nullptr; + } + + Controller &con = it->second; + + return &con; + } + /***** AstroCam::Interface::get_controller **********************************/ + + + /***** AstroCam::Interface::get_active_controller ***************************/ + /** + * @brief helper function returns pointer to element of Controller map + * @details This will return a pointer only if the requested device is + * active. It must be configured, connected, and active. + * @param[in] dev integer device number for map indexing + * @return pointer to Controller | nullptr + * + */ + Interface::Controller* Interface::get_active_controller(const int dev) { + const std::string function("AstroCam::Interface::get_active_controller"); + std::ostringstream oss; + + auto it = this->controller.find(dev); + + if (it==this->controller.end()) { + oss << "controller for dev " << dev << " not found"; + logwrite(function, oss.str()); + return nullptr; + } + + Controller &con = it->second; + + if (!con.configured) { + oss << "controller for dev " << dev << " not configured"; + logwrite(function, oss.str()); + return nullptr; + } + + if (!con.connected) { + oss << "controller for dev " << dev << " not connected"; + logwrite(function, oss.str()); + return nullptr; + } + + if (!con.active) { + oss << "controller for dev " << dev << " not active"; + logwrite(function, oss.str()); + return nullptr; + } + + return &con; + } + /***** AstroCam::Interface::get_active_controller ***************************/ + + /***** AstroCam::Interface::add_framethread *********************************/ /** * @brief call on thread creation to increment framethreadcount @@ -5497,6 +5666,8 @@ logwrite(function, message.str()); this->pArcDev = NULL; this->pCallback = NULL; this->connected = false; + this->configured = false; + this->active = false; this->is_imsize_set = false; this->firmwareloaded = false; this->firmware = ""; @@ -5822,6 +5993,7 @@ logwrite(function, message.str()); retstring.append( " telem ? | collect | test | calibd | flexured | focusd | tcsd\n" ); retstring.append( " isreadout\n" ); retstring.append( " pixelcount\n" ); + retstring.append( " devnums\n" ); return HELP; } @@ -5856,8 +6028,8 @@ logwrite(function, message.str()); } std::string msg; this->camera.set_fitstime( get_timestamp( ) ); // must set camera.fitstime first - if ( this->devnums.size() > 1 ) { - for (const auto &dev : this->devnums) { + if ( this->active_devnums.size() > 1 ) { + for (const auto &dev : this->active_devnums) { this->camera.get_fitsname( std::to_string(dev), msg ); // get the fitsname (by reference) this->camera.async.enqueue( msg ); // queue the fitsname logwrite( function, msg ); // log ths fitsname @@ -6178,18 +6350,18 @@ logwrite(function, message.str()); { std::vector pending = this->exposure_pending_list(); message.str(""); message << "exposures pending: "; - for ( const auto &dev : pending ) message << this->controller[dev].channel << " "; + for ( const auto &dev : pending ) message << this->controller.at(dev).channel << " "; logwrite( function, message.str() ); } retstring.append( message.str() ); retstring.append( "\n" ); message.str(""); message << "in readout: "; - for ( const auto &dev : this->devnums ) if ( this->controller[dev].in_readout ) message << this->controller[dev].channel << " "; + for ( const auto &dev : this->active_devnums ) if ( this->controller.at(dev).in_readout ) message << this->controller.at(dev).channel << " "; logwrite( function, message.str() ); retstring.append( message.str() ); retstring.append( "\n" ); message.str(""); message << "in frametransfer: "; - for ( const auto &dev : this->devnums ) if ( this->controller[dev].in_frametransfer ) message << this->controller[dev].channel << " "; + for ( const auto &dev : this->active_devnums ) if ( this->controller.at(dev).in_frametransfer ) message << this->controller.at(dev).channel << " "; logwrite( function, message.str() ); retstring.append( message.str() ); retstring.append( "\n" ); @@ -6225,27 +6397,27 @@ logwrite(function, message.str()); retstring.append( " Initiate the frame transfer waveforms on the indicated device.\n" ); retstring.append( " Supply dev# or chan from { " ); message.str(""); - for ( const auto &dd : this->devnums ) { + for ( const auto &dd : this->active_devnums ) { message << dd << " " << this->controller[dd].channel << " "; } - if ( this->devnums.empty() ) message << "no_devices_open "; + if ( this->active_devnums.empty() ) message << "no_active_devices "; message << "}"; retstring.append( message.str() ); return HELP; } - // must have at least one device open + // must have at least one active device open // - if ( this->devnums.empty() ) { + if ( this->active_devnums.empty() ) { logwrite( function, "ERROR: no open devices" ); retstring="no_devices"; return( ERROR ); } - // check if arg is a channel by comparing to all the defined channels in the devnums + // check if arg is a channel by comparing to all the defined channels in the active_devnums // int dev=-1; - for ( const auto &dd : this->devnums ) { + for ( const auto &dd : this->active_devnums ) { if ( this->controller[dd].channel == tokens[1] ) { dev = dd; break; @@ -6271,13 +6443,13 @@ logwrite(function, message.str()); // initiate the frame transfer waveforms // - this->controller[dev].pArcDev->frame_transfer( 0, - this->controller[dev].devnum, - this->controller[dev].info.axes[_ROW_], - this->controller[dev].info.axes[_COL_], - this->controller[dev].pCallback + this->controller.at(dev).pArcDev->frame_transfer( 0, + this->controller.at(dev).devnum, + this->controller.at(dev).info.axes[_ROW_], + this->controller.at(dev).info.axes[_COL_], + this->controller.at(dev).pCallback ); - retstring=this->controller[dev].channel; + retstring=this->controller.at(dev).channel; return( NO_ERROR ); } else @@ -6289,7 +6461,7 @@ logwrite(function, message.str()); if ( testname == "controller" ) { for ( auto &con : this->controller ) { - if ( con.second.inactive ) continue; // skip controllers flagged as inactive + if (!con.second.configured) continue; // skip controllers not configured message.str(""); message << "controller[" << con.second.devnum << "] connected:" << ( con.second.connected ? "T" : "F" ) << " bufsize:" << con.second.get_bufsize() << " rows:" << con.second.rows << " cols:" << con.second.cols @@ -6437,6 +6609,24 @@ logwrite(function, message.str()); return ERROR; } } + else + // ---------------------------------------------------- + // devnums + // ---------------------------------------------------- + // print the *_devnums vectors + // + if ( testname == "devnums" ) { + std::ostringstream oss; + oss << "configured="; + for (const auto &dev : this->configured_devnums) oss << dev << " "; + oss << " active="; + for (const auto &dev : this->active_devnums) oss << dev << " "; + oss << " connected="; + for (const auto &dev : this->connected_devnums) oss << dev << " "; + logwrite(function, oss.str()); + retstring=oss.str(); + return NO_ERROR; + } else { // ---------------------------------------------------- // invalid test name diff --git a/camerad/astrocam.h b/camerad/astrocam.h index 93e8cfe8..9866b6ea 100644 --- a/camerad/astrocam.h +++ b/camerad/astrocam.h @@ -46,6 +46,12 @@ namespace AstroCam { const int NUM_EXPBUF = 3; // number of exposure buffers + enum class ActiveState { + Activate, + DeActivate, + Query + }; + /** * ENUM list for each readout type */ @@ -590,7 +596,8 @@ namespace AstroCam { int num_deinter_thr; //!< number of threads that can de-interlace an image int numdev; //!< total number of Arc devices detected in system std::vector configured_devnums; //!< vector of configured Arc devices (from camerad.cfg file) - std::vector devnums; //!< vector of all opened and connected devices + std::vector active_devnums; //!< vector of active Arc devices + std::vector connected_devnums; //!< vector of all open and connected devices std::mutex epend_mutex; std::vector exposures_pending; //!< vector of devnums that have a pending exposure (which needs to be stored) @@ -600,6 +607,16 @@ namespace AstroCam { void retval_to_string( std::uint32_t check_retval, std::string& retstring ); + inline void remove_dev(const int dev, std::vector &vec) { + auto it = std::find(vec.begin(), vec.end(), dev); + if ( it != vec.end() ) vec.erase(it); + } + + inline void add_dev(const int dev, std::vector &vec) { + auto it = std::find(vec.begin(), vec.end(), dev); + if ( it == vec.end() ) vec.push_back(dev); + } + public: Interface(); @@ -659,7 +676,7 @@ std::vector> fitsinfo; inline bool is_camera_idle() { int num=0; - for ( auto dev : this->devnums ) { + for ( auto dev : this->connected_devnums ) { num += ( this->controller[dev].in_readout ? 1 : 0 ); num += ( this->controller[dev].in_frametransfer ? 1 : 0 ); } @@ -670,7 +687,7 @@ std::vector> fitsinfo; inline bool in_readout() const { int num=0; - for ( auto dev : this->devnums ) { + for ( auto dev : this->connected_devnums ) { num += ( this->controller.at(dev).in_readout ? 1 : 0 ); num += ( this->controller.at(dev).in_frametransfer ? 1 : 0 ); } @@ -679,7 +696,7 @@ std::vector> fitsinfo; inline bool in_frametransfer() const { int num=0; - for ( auto dev : this->devnums ) { + for ( auto dev : this->connected_devnums ) { num += ( this->controller.at(dev).in_frametransfer ? 1 : 0 ); } return( num==0 ? false : true ); @@ -910,7 +927,8 @@ std::vector> fitsinfo; arc::gen3::CArcDevice* pArcDev; //!< arc::CController object pointer -- things pointed to by this are in the ARC API Callback* pCallback; //!< Callback class object must be pointer because the API functions are virtual bool connected; //!< true if controller connected (requires successful TDL command) - bool inactive; //!< set true to skip future use of controllers when unable to connect + bool configured; //!< set false to skip future use of controllers when unable to connect + bool active; //!< used to disable an otherwise-configured controller bool is_imsize_set; //!< has image_size been called after controller connected? bool firmwareloaded; //!< true if firmware is loaded, false otherwise std::string firmware; //!< name of firmware (.lod) file @@ -978,6 +996,9 @@ std::vector> fitsinfo; // Functions // + long camera_active_state(const std::string &args, std::string &retstring, AstroCam::ActiveState cmd); + Controller* get_controller(const int dev); + Controller* get_active_controller(const int dev); void exposure_progress(); void make_image_keywords( int dev ); long handle_json_message( std::string message_in ); @@ -1044,8 +1065,8 @@ std::vector> fitsinfo; */ long do_native(std::string cmdstr); ///< selected or all open controllers long do_native(std::string cmdstr, std::string &retstring); ///< selected or all open controllers, return reply - long do_native(std::vector selectdev, std::string cmdstr); ///< specified by vector - long do_native(std::vector selectdev, std::string cmdstr, std::string &retstring); ///< specified by vector + long do_native(std::vector selectdev, std::string cmdstr); ///< specified by vector + long do_native(std::vector selectdev, std::string cmdstr, std::string &retstring); ///< specified by vector long do_native(int dev, std::string cmdstr, std::string &retstring); ///< specified by devnum long write_frame( int expbuf, int devnum, const std::string chan, int fpbcount ); diff --git a/camerad/camerad.cpp b/camerad/camerad.cpp index 23dda522..7425ce58 100644 --- a/camerad/camerad.cpp +++ b/camerad/camerad.cpp @@ -598,6 +598,18 @@ void doit(Network::TcpSocket &sock) { } #ifdef ASTROCAM else + if ( cmd == CAMERAD_ACTIVATE ) { + ret=server.camera_active_state(args, retstring, AstroCam::ActiveState::Activate); + } + else + if ( cmd == CAMERAD_DEACTIVATE ) { + ret=server.camera_active_state(args, retstring, AstroCam::ActiveState::DeActivate); + } + else + if ( cmd == CAMERAD_ISACTIVE ) { + ret=server.camera_active_state(args, retstring, AstroCam::ActiveState::Query); + } + else if ( cmd == CAMERAD_MODEXPTIME ) { ret = server.modify_exptime(args, retstring); } diff --git a/common/camerad_commands.h b/common/camerad_commands.h index 2411bdc7..97ad2ce3 100644 --- a/common/camerad_commands.h +++ b/common/camerad_commands.h @@ -9,6 +9,7 @@ #pragma once const std::string CAMERAD_ABORT = "abort"; +const std::string CAMERAD_ACTIVATE = "activate"; const std::string CAMERAD_AUTODIR = "autodir"; const std::string CAMERAD_BASENAME = "basename"; const std::string CAMERAD_BIAS = "bias"; @@ -17,6 +18,7 @@ const std::string CAMERAD_BOI = "boi"; const std::string CAMERAD_BUFFER = "buffer"; const std::string CAMERAD_CLOSE = "close"; const std::string CAMERAD_CONFIG = "config"; +const std::string CAMERAD_DEACTIVATE = "deactivate"; const std::string CAMERAD_ECHO = "echo"; const std::string CAMERAD_EXPOSE = "expose"; const std::string CAMERAD_EXPTIME = "exptime"; @@ -28,6 +30,7 @@ const std::string CAMERAD_IMDIR = "imdir"; const std::string CAMERAD_IMNUM = "imnum"; const std::string CAMERAD_IMSIZE = "imsize"; const std::string CAMERAD_INTERFACE = "interface"; +const std::string CAMERAD_ISACTIVE = "isactive"; const std::string CAMERAD_ISOPEN = "isopen"; const std::string CAMERAD_KEY = "key"; const std::string CAMERAD_LOAD = "load"; @@ -48,6 +51,7 @@ const std::string CAMERAD_USEFRAMES = "useframes"; const std::string CAMERAD_WRITEKEYS = "writekeys"; const std::vector CAMERAD_SYNTAX = { CAMERAD_ABORT, + CAMERAD_ACTIVATE, CAMERAD_AUTODIR, CAMERAD_BASENAME, CAMERAD_BIAS, @@ -56,6 +60,7 @@ const std::vector CAMERAD_SYNTAX = { CAMERAD_BUFFER+" ? | | [ | ]", CAMERAD_CLOSE, CAMERAD_CONFIG, + CAMERAD_DEACTIVATE, CAMERAD_ECHO, CAMERAD_EXPOSE, CAMERAD_EXPTIME+" [ ]", @@ -67,6 +72,7 @@ const std::vector CAMERAD_SYNTAX = { CAMERAD_IMNUM, CAMERAD_IMSIZE+" ? | | [ ]", CAMERAD_INTERFACE, + CAMERAD_ISACTIVE, CAMERAD_ISOPEN, CAMERAD_KEY, CAMERAD_LOAD, From 738cf728d2a62bb7f56ca7e20c7d76846b6d1b04 Mon Sep 17 00:00:00 2001 From: David Hale Date: Fri, 30 Jan 2026 00:58:25 -0800 Subject: [PATCH 03/74] fixes bug: controller[dev] map wasn't being created --- camerad/astrocam.cpp | 149 +++++++++++++++++-------------------------- camerad/astrocam.h | 31 +++++++-- 2 files changed, 84 insertions(+), 96 deletions(-) diff --git a/camerad/astrocam.cpp b/camerad/astrocam.cpp index 9328c144..3a840e82 100644 --- a/camerad/astrocam.cpp +++ b/camerad/astrocam.cpp @@ -518,55 +518,44 @@ namespace AstroCam { * */ long Interface::parse_controller_config( std::string args ) { - std::string function = "AstroCam::Interface::parse_controller_config"; - std::stringstream message; - std::vector tokens; + const std::string function("AstroCam::Interface::parse_controller_config"); + std::ostringstream message; logwrite( function, args ); - int dev, readout_type=-1; - uint32_t readout_arg=0xBAD; - std::string chan, id, firm, amp; - bool ft, readout_valid=false; + std::istringstream iss(args); - Tokenize( args, tokens, " " ); + int readout_type=-1; + uint32_t readout_arg=0xBAD; + bool readout_valid=false; - if ( tokens.size() != 6 ) { - message.str(""); message << "ERROR: bad value \"" << args << "\". expected { PCIDEV CHAN ID FT FIRMWARE READOUT }"; - logwrite( function, message.str() ); - return( ERROR ); + int dev; + std::string chan, id, firm, amp, ft; + bool have_ft; + + if (!(iss >> dev + >> chan + >> id + >> firm + >> amp + >> ft)) { + logwrite(function, "ERROR bad config. expected { PCIDEV CHAN ID FT FIRMWARE READOUT }"); + return ERROR; } - try { - dev = std::stoi( tokens.at(0) ); - chan = tokens.at(1); - id = tokens.at(2); - firm = tokens.at(4); - amp = tokens.at(5); - if ( tokens.at(3) == "yes" ) ft = true; - else - if ( tokens.at(3) == "no" ) ft = false; - else { - message.str(""); message << "unrecognized value for FT: " << tokens.at(2) << ". Expected { yes | no }"; - this->camera.log_error( function, message.str() ); - return( ERROR ); - } - } - catch (std::invalid_argument &) { - this->camera.log_error( function, "invalid number: unable to convert to integer" ); - return(ERROR); - } - catch (std::out_of_range &) { - this->camera.log_error( function, "value out of integer range" ); - return(ERROR); + if (ft=="yes") have_ft=true; + else if (ft=="no") have_ft=false; + else { + logwrite(function, "ERROR. FT expected { yes | no }"); + return ERROR; } // Check the PCIDEV number is in expected range // if ( dev < 0 || dev > 3 ) { - message.str(""); message << "ERROR: bad PCIDEV " << dev << ". Expected {0,1,2,3}"; + message << "ERROR: bad PCIDEV " << dev << ". Expected {0,1,2,3}"; this->camera.log_error( function, message.str() ); - return( ERROR ); + return ERROR; } // Check that READOUT has a match in the list of known readout amps. @@ -595,50 +584,54 @@ namespace AstroCam { // // The first four come from the config file, the rest are defaults // - this->controller.at(dev).devnum = dev; // device number - this->controller.at(dev).channel = chan; // spectrographic channel - this->controller.at(dev).ccd_id = id; // CCD identifier - this->controller.at(dev).have_ft = ft; // frame transfer supported? - this->controller.at(dev).firmware = firm; // firmware file + + // create a local reference indexed by dev + Controller &con = this->controller[dev]; + + con.devnum = dev; // device number + con.channel = chan; // spectrographic channel + con.ccd_id = id; // CCD identifier + con.have_ft = have_ft; // frame transfer supported? + con.firmware = firm; // firmware file /* arc::gen3::CArcDevice* pArcDev = NULL; // create a generic CArcDevice pointer pArcDev = new arc::gen3::CArcPCI(); // point this at a new CArcPCI() object ///< TODO @todo implement PCIe option Callback* pCB = new Callback(); // create a pointer to a Callback() class object - this->controller.at(dev).pArcDev = pArcDev; // set the pointer to this object in the public vector - this->controller.at(dev).pCallback = pCB; // set the pointer to this object in the public vector + this->controller[dev].pArcDev = pArcDev; // set the pointer to this object in the public vector + this->controller[dev].pCallback = pCB; // set the pointer to this object in the public vector */ - this->controller.at(dev).pArcDev = ( new arc::gen3::CArcPCI() ); // set the pointer to this object in the public vector - this->controller.at(dev).pCallback = ( new Callback() ); // set the pointer to this object in the public vector - this->controller.at(dev).devname = ""; // device name - this->controller.at(dev).connected = false; // not yet connected - this->controller.at(dev).is_imsize_set = false; // need to set image_size - this->controller.at(dev).firmwareloaded = false; // no firmware loaded + con.pArcDev = ( new arc::gen3::CArcPCI() ); // set the pointer to this object in the public vector + con.pCallback = ( new Callback() ); // set the pointer to this object in the public vector + con.devname = ""; // device name + con.connected = false; // not yet connected + con.is_imsize_set = false; // need to set image_size + con.firmwareloaded = false; // no firmware loaded - this->controller.at(dev).info.readout_name = amp; - this->controller.at(dev).info.readout_type = readout_type; - this->controller.at(dev).readout_arg = readout_arg; + con.info.readout_name = amp; + con.info.readout_type = readout_type; + con.readout_arg = readout_arg; this->exposure_pending( dev, false ); - this->controller.at(dev).in_readout = false; - this->controller.at(dev).in_frametransfer = false; + con.in_readout = false; + con.in_frametransfer = false; this->state_monitor_condition.notify_all(); // configured by config file, can never be reversed unless it is removed from the config file - this->controller.at(dev).configured = true; + con.configured = true; // configured and active. this state can be reversed on command or failure to connect // active alone isn't connected, but if not connected then it's not active - this->controller.at(dev).active = true; + con.active = true; // Header keys specific to this controller are stored in the controller's extension // - this->controller.at(dev).info.systemkeys.add_key( "CCD_ID", id, "CCD identifier parse", EXT, chan ); - this->controller.at(dev).info.systemkeys.add_key( "FT", ft, "frame transfer used", EXT, chan ); - this->controller.at(dev).info.systemkeys.add_key( "AMP_ID", amp, "CCD readout amplifier ID", EXT, chan ); - this->controller.at(dev).info.systemkeys.add_key( "SPEC_ID", chan, "spectrograph channel", EXT, chan ); - this->controller.at(dev).info.systemkeys.add_key( "DEV_ID", dev, "detector controller PCI device ID", EXT, chan ); + con.info.systemkeys.add_key( "CCD_ID", id, "CCD identifier parse", EXT, chan ); + con.info.systemkeys.add_key( "FT", ft, "frame transfer used", EXT, chan ); + con.info.systemkeys.add_key( "AMP_ID", amp, "CCD readout amplifier ID", EXT, chan ); + con.info.systemkeys.add_key( "SPEC_ID", chan, "spectrograph channel", EXT, chan ); + con.info.systemkeys.add_key( "DEV_ID", dev, "detector controller PCI device ID", EXT, chan ); // FITS_file* pFits = new FITS_file(); // create a pointer to a FITS_file class object // this->controller.at(dev).pFits = pFits; // set the pointer to this object in the public vector @@ -1369,6 +1362,10 @@ namespace AstroCam { this->active_devnums.clear(); this->connected_devnums.clear(); + // initialize the controller map + // + this->controller.clear(); + // loop through the entries in the configuration file, stored in config class // for (int entry=0; entry < this->config.n_entries; entry++) { @@ -5650,36 +5647,6 @@ logwrite(function, message.str()); /***** AstroCam::Interface::init_framethread_count **************************/ - /***** AstroCam::Interface::Controller::Controller **************************/ - /** - * @brief class constructor - * - */ - Interface::Controller::Controller() { - this->workbuf = NULL; - this->workbuf_size = 0; - this->bufsize = 0; - this->rows=0; - this->cols=0; - this->devnum = 0; - this->framecount = 0; - this->pArcDev = NULL; - this->pCallback = NULL; - this->connected = false; - this->configured = false; - this->active = false; - this->is_imsize_set = false; - this->firmwareloaded = false; - this->firmware = ""; - this->info.readout_name = ""; - this->info.readout_type = -1; - this->readout_arg = 0xBAD; - this->expinfo.resize( NUM_EXPBUF ); // vector of Camera::Information, one for each exposure buffer - this->info.exposure_unit = "msec"; // chaning unit not currently supported in ARC - } - /***** AstroCam::Interface::Controller::Controller **************************/ - - /***** AstroCam::Interface::Controller::logical_to_physical *****************/ /** * @brief translates logical (spat,spec) to physical (rows,cols) diff --git a/camerad/astrocam.h b/camerad/astrocam.h index 9866b6ea..a62e87ab 100644 --- a/camerad/astrocam.h +++ b/camerad/astrocam.h @@ -667,8 +667,8 @@ std::vector> fitsinfo; */ inline bool is_camera_idle( int dev ) { int num=0; - num += ( this->controller[dev].in_readout ? 1 : 0 ); - num += ( this->controller[dev].in_frametransfer ? 1 : 0 ); + num += ( this->controller.at(dev).in_readout ? 1 : 0 ); + num += ( this->controller.at(dev).in_frametransfer ? 1 : 0 ); std::lock_guard lock( this->epend_mutex ); num += this->exposures_pending.size(); return ( num>0 ? false : true ); @@ -677,8 +677,8 @@ std::vector> fitsinfo; inline bool is_camera_idle() { int num=0; for ( auto dev : this->connected_devnums ) { - num += ( this->controller[dev].in_readout ? 1 : 0 ); - num += ( this->controller[dev].in_frametransfer ? 1 : 0 ); + num += ( this->controller.at(dev).in_readout ? 1 : 0 ); + num += ( this->controller.at(dev).in_frametransfer ? 1 : 0 ); } std::lock_guard lock( this->epend_mutex ); num += this->exposures_pending.size(); @@ -867,7 +867,28 @@ std::vector> fitsinfo; long workbuf_size; public: - Controller(); //!< class constructor + Controller() + : bufsize(0), + framecount(0), + workbuf_size(0), + info(), + workbuf(nullptr), + cols(0), + rows(0), + pArcDev(nullptr), + pCallback(nullptr), + connected(false), + configured(false), + active(false), + is_imsize_set(false), + firmwareloaded(false) + { + info.readout_type = -1; + readout_arg = 0xBAD; + expinfo.resize( NUM_EXPBUF ); // vector of Camera::Information, one for each exposure buffer + info.exposure_unit = "msec"; // chaning unit not currently supported in ARC + } + ~Controller() { }; //!< no deconstructor Camera::Information info; //!< camera info object for this controller From d98d199cc294c16bfe000043c735ad65d18477e2 Mon Sep 17 00:00:00 2001 From: David Hale Date: Fri, 30 Jan 2026 11:15:44 -0800 Subject: [PATCH 04/74] bug fix Interface::parse_controller_config --- camerad/astrocam.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/camerad/astrocam.cpp b/camerad/astrocam.cpp index 3a840e82..ec6ea72d 100644 --- a/camerad/astrocam.cpp +++ b/camerad/astrocam.cpp @@ -536,9 +536,9 @@ namespace AstroCam { if (!(iss >> dev >> chan >> id + >> ft >> firm - >> amp - >> ft)) { + >> amp)) { logwrite(function, "ERROR bad config. expected { PCIDEV CHAN ID FT FIRMWARE READOUT }"); return ERROR; } From 794cbe8efbf3d57556ef79b8d64448e8c82d869c Mon Sep 17 00:00:00 2001 From: David Hale Date: Fri, 30 Jan 2026 11:32:00 -0800 Subject: [PATCH 05/74] gets latest camerad.cfg.in from main --- Config/camerad.cfg.in | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Config/camerad.cfg.in b/Config/camerad.cfg.in index 630a4666..fe3e69bb 100644 --- a/Config/camerad.cfg.in +++ b/Config/camerad.cfg.in @@ -31,9 +31,9 @@ ARCSIM_NUMDEV=0 # #CONTROLLER=(0 I sg2 yes /home/developer/Software/DSP/lod/sg2-20241115.lod U2) #CONTROLLER=(1 R engg yes /home/developer/Software/DSP/lod/engg-20241115.lod U1) -CONTROLLER=(0 I sg2 yes /home/developer/Software/DSP/lod/28-1458/sg2.lod U2) -CONTROLLER=(1 R engg yes /home/developer/Software/DSP/lod/28-1458/engg.lod U1) -CONTROLLER=(2 G dbspr yes /home/developer/Software/DSP/lod/51209-1651/dbspr.lod U2) +CONTROLLER=(0 I sg2 yes /home/developer/Software/DSP/lod/260127-1318/sg2.lod U2) +CONTROLLER=(1 R engg yes /home/developer/Software/DSP/lod/260127-1318/engg.lod U1) +CONTROLLER=(2 G dbspr yes /home/developer/Software/DSP/lod/260127-1318/dbspr.lod L1) CONTROLLER=(3 U dbspb no /home/developer/Software/DSP/DBSP-blue/tim.lod U1) # ----------------------------------------------------------------------------- From 54b589effad2a638b82aa0133408ff9441bbc26a Mon Sep 17 00:00:00 2001 From: David Hale Date: Fri, 30 Jan 2026 11:50:42 -0800 Subject: [PATCH 06/74] fixes bug in Interface::image_size --- camerad/astrocam.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/camerad/astrocam.cpp b/camerad/astrocam.cpp index ec6ea72d..000f1ca3 100644 --- a/camerad/astrocam.cpp +++ b/camerad/astrocam.cpp @@ -4793,7 +4793,12 @@ logwrite(function, message.str()); std::vector tokens; Tokenize( retstring, tokens, " " ); - Controller* pcontroller = this->get_active_controller(dev); + // Just need to get a configured controller here, + // it doesn't need to be active or connected at this stage. + // This allows setting up image size prior to connecting, which is done + // when the config file is read. + // + Controller* pcontroller = this->get_controller(dev); if (!pcontroller) { logwrite(function, "ERROR: controller not available for channel "+chan); From 6c0175eab34d4d5a56ec65ff33ce99ed3c72511c Mon Sep 17 00:00:00 2001 From: David Hale Date: Fri, 30 Jan 2026 12:07:00 -0800 Subject: [PATCH 07/74] additional checks for inactive controllers --- camerad/astrocam.cpp | 58 +++++++++++++++++++++++++++++--------------- 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/camerad/astrocam.cpp b/camerad/astrocam.cpp index 000f1ca3..2359b931 100644 --- a/camerad/astrocam.cpp +++ b/camerad/astrocam.cpp @@ -744,8 +744,11 @@ namespace AstroCam { // for ( const auto &con : this->controller ) { #ifdef LOGLEVEL_DEBUG - message.str(""); message << "[DEBUG] con.first=" << con.first << " con.second.channel=" << con.second.channel - << " .devnum=" << con.second.devnum << " .configured=" << (con.second.configured?"T":"F") << " .active=" << (con.second.active?"T":"F"); + message.str(""); message << "[DEBUG] con.first=" << con.first + << " con.second.channel=" << con.second.channel + << " .devnum=" << con.second.devnum + << " .configured=" << (con.second.configured?"T":"F") + << " .active=" << (con.second.active?"T":"F"); logwrite( function, message.str() ); #endif if (!con.second.configured) continue; // skip controllers not configured @@ -1284,7 +1287,7 @@ namespace AstroCam { return( ERROR ); } - // close all of the PCI devices + // close all of the PCI devices regardless of active status // for ( auto &con : this->controller ) { message.str(""); message << "closing " << con.second.devname; @@ -3365,9 +3368,9 @@ namespace AstroCam { // for ( const auto &con : this->controller ) { if (!con.second.configured) continue; // skip controllers not configured - // But only use it if the device is open + // But only use it if the device is open and active // - if ( con.second.connected ) { + if ( con.second.connected && con.second.active ) { std::stringstream lodfilestream; lodfilestream << con.second.devnum << " " << con.second.firmware; @@ -3668,7 +3671,8 @@ for ( const auto &dev : selectdev ) { retstring.append( " Specify from { " ); message.str(""); for ( const auto &con : this->controller ) { - if (!con.second.configured) continue; // skip controllers not configured + // skip unconfigured and inactive controllers + if (!con.second.configured || !con.second.active) continue; message << con.second.channel << " "; } message << "}\n"; @@ -3676,7 +3680,8 @@ for ( const auto &dev : selectdev ) { retstring.append( " or from { " ); message.str(""); for ( const auto &con : this->controller ) { - if (!con.second.configured) continue; // skip controllers not configured + // skip unconfigured and inactive controllers + if (!con.second.configured || !con.second.active) continue; message << con.second.devnum << " "; } message << "}\n"; @@ -3798,7 +3803,8 @@ for ( const auto &dev : selectdev ) { retstring.append( " Specify from { " ); message.str(""); for ( const auto &con : this->controller ) { - if (!con.second.configured) continue; // skip controllers not configured + // skip unconfigured and inactive controllers + if (!con.second.configured || !con.second.active) continue; message << con.second.channel << " "; } message << "}\n"; @@ -3806,7 +3812,8 @@ for ( const auto &dev : selectdev ) { retstring.append( " or from { " ); message.str(""); for ( const auto &con : this->controller ) { - if (!con.second.configured) continue; // skip controllers not configured + // skip unconfigured and inactive controllers + if (!con.second.configured || !con.second.active) continue; message << con.second.devnum << " "; } message << "}\n"; @@ -3918,7 +3925,8 @@ for ( const auto &dev : selectdev ) { retstring.append( " Specify from { " ); message.str(""); for ( const auto &con : this->controller ) { - if (!con.second.configured) continue; // skip controllers not configured + // skip unconfigured and inactive controllers + if (!con.second.configured || !con.second.active) continue; message << con.second.channel << " "; } message << "}\n"; @@ -3926,7 +3934,8 @@ for ( const auto &dev : selectdev ) { retstring.append( " or from { " ); message.str(""); for ( const auto &con : this->controller ) { - if (!con.second.configured) continue; // skip controllers not configured + // skip unconfigured and inactive controllers + if (!con.second.configured || !con.second.active) continue; message << con.second.devnum << " "; } message << "}\n"; @@ -4289,6 +4298,7 @@ logwrite(function, message.str()); if ( this->in_readout() ) { message.str(""); message << "ERROR: cannot change exposure time while reading out chan "; for ( const auto &con : this->controller ) { + if (!con.second.active) continue; // skip inactive controllers if ( con.second.in_readout || con.second.in_frametransfer ) message << con.second.channel << " "; } this->camera.async.enqueue_and_log( "CAMERAD", function, message.str() ); @@ -4656,7 +4666,8 @@ logwrite(function, message.str()); retstring.append( " Specify from { " ); message.str(""); for ( const auto &con : this->controller ) { - if (!con.second.configured) continue; // skip controllers not configured + // skip unconfigured and inactive controllers + if (!con.second.configured || !con.second.active) continue; message << con.second.channel << " "; } message << "}\n"; @@ -4664,7 +4675,8 @@ logwrite(function, message.str()); retstring.append( " or from { " ); message.str(""); for ( const auto &con : this->controller ) { - if (!con.second.configured) continue; // skip controllers not configured + // skip unconfigured and inactive controllers + if (!con.second.configured || !con.second.active) continue; message << con.second.devnum << " "; } message << "}\n"; @@ -4755,7 +4767,8 @@ logwrite(function, message.str()); retstring.append( " Specify from { " ); message.str(""); for ( const auto &con : this->controller ) { - if (!con.second.configured) continue; // skip controllers not configured + // skip unconfigured and inactive controllers + if (!con.second.configured || !con.second.active) continue; message << con.second.channel << " "; } message << "}\n"; @@ -4763,7 +4776,8 @@ logwrite(function, message.str()); retstring.append( " or from { " ); message.str(""); for ( const auto &con : this->controller ) { - if (!con.second.configured) continue; // skip controllers not configured + // skip unconfigured and inactive controllers + if (!con.second.configured || !con.second.active) continue; message << con.second.devnum << " "; } message << "}\n"; @@ -5082,7 +5096,8 @@ logwrite(function, message.str()); retstring.append( " Specify from { " ); message.str(""); for ( const auto &con : this->controller ) { - if (!con.second.configured) continue; // skip controllers not configured + // skip unconfigured and inactive controllers + if (!con.second.configured || !con.second.active) continue; message << con.second.channel << " "; } message << "}\n"; @@ -5090,7 +5105,8 @@ logwrite(function, message.str()); retstring.append( " or from { " ); message.str(""); for ( const auto &con : this->controller ) { - if (!con.second.configured) continue; // skip controllers not configured + // skip unconfigured and inactive controllers + if (!con.second.configured || !con.second.active) continue; message << con.second.devnum << " "; } message << "}\n"; @@ -6434,7 +6450,9 @@ logwrite(function, message.str()); for ( auto &con : this->controller ) { if (!con.second.configured) continue; // skip controllers not configured - message.str(""); message << "controller[" << con.second.devnum << "] connected:" << ( con.second.connected ? "T" : "F" ) + message.str(""); message << "controller[" << con.second.devnum << "]" + << " connected:" << ( con.second.connected ? "T" : "F" ) + << " active:" << ( con.second.active ? "T" : "F" ) << " bufsize:" << con.second.get_bufsize() << " rows:" << con.second.rows << " cols:" << con.second.cols << " in_readout:" << ( con.second.in_readout ? "T" : "F" ) @@ -6541,7 +6559,7 @@ logwrite(function, message.str()); retstring.clear(); try { for ( auto &con : this->controller ) { - if ( con.second.pArcDev != nullptr && con.second.connected ) { + if ( con.second.pArcDev != nullptr && con.second.connected && con.second.active ) { bool isreadout = con.second.pArcDev->isReadout(); error=NO_ERROR; retstring += (isreadout ? "T " : "F "); @@ -6567,7 +6585,7 @@ logwrite(function, message.str()); retstring="no_controllers"; try { for ( auto &con : this->controller ) { - if ( con.second.pArcDev != nullptr && con.second.connected ) { + if ( con.second.pArcDev != nullptr && con.second.connected && con.second.active ) { uint32_t pixelcount = con.second.pArcDev->getPixelCount(); error=NO_ERROR; retstring = std::to_string(pixelcount); From 9882fbb92fbc5037febfba0a893ea727cf7fe840 Mon Sep 17 00:00:00 2001 From: David Hale Date: Fri, 30 Jan 2026 14:48:38 -0800 Subject: [PATCH 08/74] implements configurable activation commands per controller --- Config/camerad.cfg.in | 11 +++++++ camerad/astrocam.cpp | 75 ++++++++++++++++++++++++++++++++++++++----- camerad/astrocam.h | 3 ++ 3 files changed, 81 insertions(+), 8 deletions(-) diff --git a/Config/camerad.cfg.in b/Config/camerad.cfg.in index fe3e69bb..afd2d2b2 100644 --- a/Config/camerad.cfg.in +++ b/Config/camerad.cfg.in @@ -136,6 +136,17 @@ CONSTKEY_EXT=(SPECTPART WHOLE) # fixed only because we're writing single-channe # USERKEYS_PERSIST=yes +# ACTIVATE_COMMANDS=(CHAN CMD [, CMD, CMD ...]) +# commands sent to activate channel after de-activation +# CHAN is space-delimited from a comma-delimited list of one or more commands +# and must have been first configured by CONTROLER= +# commands that contain a space must be enclosed by double quotes +# +ACTIVATE_COMMANDS=(I "PON", "ERS 1000 1000", "EPG 500", "CLR") +ACTIVATE_COMMANDS=(R "PON", "ERS 1000 1000", "EPG 500", "CLR") +ACTIVATE_COMMANDS=(G "PON", "ERS 1000 1000", "EPG 500", "CLR") +ACTIVATE_COMMANDS=(U "PON", "CLR") + # ----------------------------------------------------------------------------- # TELEM_PROVIDER=( ) # diff --git a/camerad/astrocam.cpp b/camerad/astrocam.cpp index 2359b931..0118e80a 100644 --- a/camerad/astrocam.cpp +++ b/camerad/astrocam.cpp @@ -648,6 +648,57 @@ namespace AstroCam { /***** AstroCam::Interface::parse_controller_config *************************/ + /***** AstroCam::Interface::parse_activate_commands *************************/ + /** + * @brief parses the ACTIVATE_COMMANDS keywords from config file + * @param[in] args expected format is "CHAN CMD [, CMD, CMD, ...]" + * + */ + long Interface::parse_activate_commands(std::string args) { + const std::string function("AstroCam::Interface::parse_activate_commands"); + logwrite(function, args); + + std::istringstream iss(args); + + // get the channel + std::string chan; + if (!std::getline(iss, chan, ' ')) { + logwrite(function, "ERROR bad config. expected , , ..."); + return ERROR; + } + + // get device number for that channel + int dev; + try { + dev = devnum_from_chan(chan); + } + catch(const std::exception &e) { + logwrite(function, "ERROR: "+std::string(e.what())); + return ERROR; + } + + // get the list of commands + std::string cmdlist; + if (!std::getline(iss, cmdlist)) { + logwrite(function, "ERROR bad config. expected , , ..."); + return ERROR; + } + + // get a pointer to this configured controller + auto pcontroller = this->get_controller(dev); + if (!pcontroller) { + logwrite(function, "ERROR bad controller for channel "+chan); + return ERROR; + } + + // tokenize inserts each command into a vector element + Tokenize(cmdlist, pcontroller->activate_commands, ","); + + return NO_ERROR; + } + /***** AstroCam::Interface::parse_activestate_commands **********************/ + + /***** AstroCam::Interface::devnum_from_chan ********************************/ /** * @brief return the devnum associated with a channel name @@ -1390,6 +1441,14 @@ namespace AstroCam { } else + // ACTIVATE_COMMANDS + if (this->config.param[entry]=="ACTIVATE_COMMANDS") { + if (this->parse_activate_commands(this->config.arg[entry]) != ERROR) { + numapplied++; + } + } + else + if ( this->config.param[entry].find( "IMDIR" ) == 0 ) { this->camera.imdir( config.arg[entry] ); numapplied++; @@ -5513,12 +5572,16 @@ logwrite(function, message.str()); // switch (cmd) { - // first set active flag, then turn on power + // first set active flag, then send activation commands case AstroCam::ActiveState::Activate: pcontroller->active = true; // add this devnum to the active_devnums list add_dev(dev, this->active_devnums); - error=this->do_native( dev, std::string("PON"), retstring ); + // send the activation commands + for (const auto &cmd : pcontroller->activate_commands) { + error |= this->do_native(dev, std::string(cmd), retstring); + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + } break; // first turn off power, then clear active flag @@ -5554,19 +5617,15 @@ logwrite(function, message.str()); */ Interface::Controller* Interface::get_controller(const int dev) { const std::string function("AstroCam::Interface::get_controller"); - std::ostringstream oss; auto it = this->controller.find(dev); if (it==this->controller.end()) { - oss << "controller for dev " << dev << " not found"; - logwrite(function, oss.str()); + logwrite(function, "controller for dev "+std::to_string(dev)+" not found"); return nullptr; } - Controller &con = it->second; - - return &con; + return &it->second; } /***** AstroCam::Interface::get_controller **********************************/ diff --git a/camerad/astrocam.h b/camerad/astrocam.h index a62e87ab..19fdcd22 100644 --- a/camerad/astrocam.h +++ b/camerad/astrocam.h @@ -965,6 +965,8 @@ std::vector> fitsinfo; std::atomic in_readout; //!< Is the controller currently reading out/transmitting pixels? std::atomic in_frametransfer; //!< Is the controller currently performing a frame transfer? + std::vector activate_commands; + // Functions // inline uint32_t get_bufsize() { return this->bufsize; }; @@ -1026,6 +1028,7 @@ std::vector> fitsinfo; long parse_spec_info( std::string args ); long parse_det_geometry( std::string args ); long parse_controller_config( std::string args ); + long parse_activate_commands(std::string args); int devnum_from_chan( const std::string &chan ); long extract_dev_chan( std::string args, int &dev, std::string &chan, std::string &retstring ); long test(std::string args, std::string &retstring); ///< test routines From c9870180854889d65648db21572675614f8069bc Mon Sep 17 00:00:00 2001 From: David Hale Date: Fri, 30 Jan 2026 14:56:35 -0800 Subject: [PATCH 09/74] updates camerad.cfg.in --- Config/camerad.cfg.in | 8 ++++---- DSP | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Config/camerad.cfg.in b/Config/camerad.cfg.in index afd2d2b2..9c32e5d0 100644 --- a/Config/camerad.cfg.in +++ b/Config/camerad.cfg.in @@ -142,10 +142,10 @@ USERKEYS_PERSIST=yes # and must have been first configured by CONTROLER= # commands that contain a space must be enclosed by double quotes # -ACTIVATE_COMMANDS=(I "PON", "ERS 1000 1000", "EPG 500", "CLR") -ACTIVATE_COMMANDS=(R "PON", "ERS 1000 1000", "EPG 500", "CLR") -ACTIVATE_COMMANDS=(G "PON", "ERS 1000 1000", "EPG 500", "CLR") -ACTIVATE_COMMANDS=(U "PON", "CLR") +ACTIVATE_COMMANDS=(I PON, ERS 1000 1000, EPG 500, CLR) +ACTIVATE_COMMANDS=(R PON, ERS 1000 1000, EPG 500, CLR) +ACTIVATE_COMMANDS=(G PON, ERS 1000 1000, EPG 500, CLR) +ACTIVATE_COMMANDS=(U PON, CLR) # ----------------------------------------------------------------------------- # TELEM_PROVIDER=( ) diff --git a/DSP b/DSP index cfcf67af..4f4c6801 160000 --- a/DSP +++ b/DSP @@ -1 +1 @@ -Subproject commit cfcf67af5cd4d217077fff7b548191eb9de2021c +Subproject commit 4f4c6801322f52fb43347e8218552cb47829342a From 1f49d53d33a820df50061387bd3631bea5dbb612 Mon Sep 17 00:00:00 2001 From: David Hale Date: Fri, 30 Jan 2026 17:23:48 -0800 Subject: [PATCH 10/74] updates CAL_TARGET configuration table in sequencerd.cfg.in to include the activate states for the camera controllers, and updates CalibrationTarget::configure to read the new format --- Config/sequencerd.cfg.in | 30 ++++----- sequencerd/sequencer_interface.cpp | 98 ++++++++++++++---------------- sequencerd/sequencer_interface.h | 3 + 3 files changed, 63 insertions(+), 68 deletions(-) diff --git a/Config/sequencerd.cfg.in b/Config/sequencerd.cfg.in index 81c2f977..4592f094 100644 --- a/Config/sequencerd.cfg.in +++ b/Config/sequencerd.cfg.in @@ -162,26 +162,28 @@ ACQUIRE_MIN_REPEAT=2 # minimum number of sequential successful a ACQUIRE_TCS_MAX_OFFSET=60 # the maximum allowable offset sent to the TCS, in arcsec # Calibration Settings -# CAL_TARGET=(name caldoor calcover lampthar lampfear lampbluc lampredc lolamp hilamp mod1 mod2 ... mod6) +# CAL_TARGET=(name caldoor calcover U G R I lampthar lampfear lampbluc lampredc lolamp hilamp mod1 mod2 ... mod6) # # where name must be "DEFAULT" or start with "CAL_" # caldoor = open | close # calcover = open | close -# lamp* = on | off -# mod* = on | off -# for a total of 15 required parameters +# U,G,R,I = on | off # indicates which channels to enable/disable +# lamp* = on | off # lamp power +# mod* = on | off # lamp modulator +# for a total of 19 required parameters # name=SCIENCE defines science target operation # -# name door cover thar fear bluc redc llmp hlmp mod1 mod2 mod3 mod4 mod5 mod6 -CAL_TARGET=(CAL_THAR open close on off off off off off off off off off off on ) -CAL_TARGET=(CAL_FEAR open close off on off off off off on off off off off off) -CAL_TARGET=(CAL_REDCONT open close off off off on off off off off off on off off) -CAL_TARGET=(CAL_BLUCONT open close off off on off off off off off off off on off) -CAL_TARGET=(CAL_ETALON open close off off off on off off off off on off off off) -CAL_TARGET=(CAL_DOME close open off off off off off on off off off off off off) -CAL_TARGET=(CAL_BIAS close close off off off off off off off off off off off off) -CAL_TARGET=(CAL_DARK close close off off off off off off off off off off off off) -CAL_TARGET=(SCIENCE close open off off off off off off off off off off off off) +# name door cover U G R I thar fear bluc redc llmp hlmp mod1 mod2 mod3 mod4 mod5 mod6 +CAL_TARGET=(CAL_THAR open close on on on on on on on on off off off off off off off on ) +CAL_TARGET=(CAL_FEAR open close on on on on on on on on off off on off off off off off) +CAL_TARGET=(CAL_THAR_UG open close on on off off on on on on off off off off off off off on ) +CAL_TARGET=(CAL_FEAR_UG open close on on off off on on on on off off on off off off off off) +CAL_TARGET=(CAL_CONT open close on on on on on on on on off off off off off on off off) +CAL_TARGET=(CAL_DOME close open on on on on off off off off off on off off off off off off) +CAL_TARGET=(CAL_DOME_UG close open on on off off off off off off off on off off off off off off) +CAL_TARGET=(CAL_BIAS close close on on on on off off off off off off off off off off off off) +CAL_TARGET=(CAL_DARK close close on on on on off off off off off off off off off off off off) +CAL_TARGET=(SCIENCE close open on on on on off off off off off off off off off off off off) # miscellaneous # diff --git a/sequencerd/sequencer_interface.cpp b/sequencerd/sequencer_interface.cpp index 1550662d..eecf09c8 100644 --- a/sequencerd/sequencer_interface.cpp +++ b/sequencerd/sequencer_interface.cpp @@ -943,15 +943,26 @@ namespace Sequencer { */ long CalibrationTarget::configure( const std::string &args ) { const std::string function("Sequencer::CalibrationTarget::configure"); - std::stringstream message; std::vector tokens; + // helpers + auto on_off = [](const std::string &s) { + if (s=="on") return true; + if (s=="off") return false; + throw std::runtime_error("expected on|off but got '"+s+"'"); + }; + auto open_close = [](const std::string &s) { + if (s=="open") return true; + if (s=="close") return false; + throw std::runtime_error("expected open|close but got '"+s+"'"); + }; + auto size = Tokenize( args, tokens, " \t" ); - // there must be 15 args. see cfg file for complete description - if ( size != 15 ) { - message << "ERROR expected 15 but received " << size << " parameters"; - logwrite( function, message.str() ); + // there must be 19 args. see cfg file for complete description + if ( size != 19 ) { + logwrite(function, "ERROR bad config file. expected 19 but received " + +std::to_string(size)+" parameters"); return ERROR; } @@ -960,64 +971,43 @@ namespace Sequencer { std::string name(tokens[0]); if ( name.empty() || ( name != "SCIENCE" && name.compare(0, 4, "CAL_") !=0 ) ) { - message << "ERROR invalid calibration target name \"" << name << "\": must be \"SCIENCE\" or start with \"CAL_\" "; + logwrite(function, "ERROR invalid calibration target name '"+name + +"': must be 'SCIENCE' or start with 'CAL_' "); return ERROR; } - this->calmap[name].name = name; - // token[1] = caldoor - if ( tokens[1].empty() || - ( tokens[1].find("open")==std::string::npos && tokens[1].find("close") ) ) { - message << "ERROR invalid caldoor \"" << tokens[1] << "\": expected {open|close}"; - return ERROR; - } - this->calmap[name].caldoor = (tokens.at(1).find("open")==0); + // create map and get a reference to use for the remaining values + calinfo_t &info = this->calmap[name]; + info.name = name; - // token[2] = calcover - if ( tokens[2].empty() || - ( tokens[2].find("open")==std::string::npos && tokens[2].find("close") ) ) { - message << "ERROR invalid calcover \"" << tokens[2] << "\": expected {open|close}"; - return ERROR; - } - this->calmap[name].calcover = (tokens.at(2).find("open")==0); - - // tokens[3:6] = LAMPTHAR, LAMPFEAR, LAMPBLUC, LAMPREDC - int n=3; // incremental token counter used for the following groups - for ( const auto &lamp : this->lampnames ) { - if ( tokens[n].empty() || - ( tokens[n].find("on")==std::string::npos && tokens[n].find("off")==std::string::npos ) ) { - message << "ERROR invalid state \"" << tokens[n] << "\" for " << lamp << ": expected {on|off}"; - logwrite( function, message.str() ); - return ERROR; + try { + // tokens 1-2 are caldoor and calcover + info.caldoor = open_close(tokens.at(1)); + info.calcover = open_close(tokens.at(2)); + + // tokens 3-6 are the channel active states, indexed by channel name + for (size_t i=0; i < 4; i++) { + info.channel_active[chans.at(i)] = on_off(tokens.at(3+i)); } - this->calmap[name].lamp[lamp] = (tokens[n].find("on")==0); - n++; - } - // tokens[7:8] = domelamps - // i indexes domelampnames vector {0,1} - // j indexes domelamp map {1,2} - for ( int i=0,j=1; j<=2; i++,j++ ) { - if ( tokens[n].empty() || - ( tokens[n].find("on")==std::string::npos && tokens[n].find("off")==std::string::npos ) ) { - message << "ERROR invalid state \"" << tokens[n] << "\" for " << domelampnames[i] << ": expected {on|off}"; - logwrite( function, message.str() ); - return ERROR; + // tokens 7-10 are lamp states LAMPTHAR, LAMPFEAR, LAMPBLUC, LAMPREDC + for (size_t i=0; i < 4; i++) { + info.lamp[lampnames.at(i)] = on_off(tokens.at(7+i)); } - this->calmap[name].domelamp[j] = (tokens[n].find("on")==0); - n++; - } - // tokens[0:14] = lampmod{1:6} - for ( int i=1; i<=6; i++ ) { - if ( tokens[n].empty() || - ( tokens[n].find("on")==std::string::npos && tokens[n].find("off")==std::string::npos ) ) { - message << "ERROR invalid state \"" << tokens[n] << "\" for lampmod" << n << ": expected {on|off}"; - logwrite( function, message.str() ); - return ERROR; + // tokens 11-12 are dome lamps + for (size_t i=0; i < 2; i++) { + info.domelamp.at(i) = on_off(tokens.at(11+i)); + } + + // tokens 13-19 + for (size_t i=0; i<6; i++) { + info.lampmod[i] = on_off(tokens.at(13+1)); } - this->calmap[name].lampmod[i] = (tokens[n].find("on")==0); - n++; + } + catch (const std::exception &e) { + logwrite(function, "ERROR: "+std::string(e.what())); + return ERROR; } return NO_ERROR; diff --git a/sequencerd/sequencer_interface.h b/sequencerd/sequencer_interface.h index a90dcced..f3f7f83f 100644 --- a/sequencerd/sequencer_interface.h +++ b/sequencerd/sequencer_interface.h @@ -132,12 +132,14 @@ namespace Sequencer { class CalibrationTarget { public: CalibrationTarget() : + chans { "U", "G", "R", "I" }, lampnames { "LAMPTHAR", "LAMPFEAR", "LAMPBLUC", "LAMPREDC" }, domelampnames { "LOLAMP", "HILAMP" } { } ///< struct holds all calibration parameters not in the target database typedef struct { std::string name; // calibration target name + std::map channel_active; // true=on bool caldoor; // true=open bool calcover; // true=open std::map lamp; // true=on @@ -160,6 +162,7 @@ namespace Sequencer { private: std::unordered_map calmap; + std::vector chans; std::vector lampnames; std::vector domelampnames; }; From ea20b72338fa98b0e56bbc2bacc3c97cc0ae921c Mon Sep 17 00:00:00 2001 From: David Hale Date: Mon, 2 Feb 2026 08:21:25 -0800 Subject: [PATCH 11/74] adds minimum to compile with zmq pub-sub --- Config/camerad.cfg.in | 7 +++++ camerad/CMakeLists.txt | 7 +++++ camerad/astrocam.cpp | 50 +++-------------------------------- camerad/astrocam.h | 59 +++++++++++++++++++++++++++++++++++++++++- camerad/camerad.cpp | 8 ++++++ 5 files changed, 84 insertions(+), 47 deletions(-) diff --git a/Config/camerad.cfg.in b/Config/camerad.cfg.in index 9c32e5d0..e90faefd 100644 --- a/Config/camerad.cfg.in +++ b/Config/camerad.cfg.in @@ -15,6 +15,13 @@ ASYNCGROUP=239.1.1.234 # or set to NONE if not using ASYNC messaging DAEMON=no # run as a daemon? {yes,no} PUBLISHER_PORT="tcp://127.0.0.1:@CAMERAD_PUB_PORT@" # my zeromq pub port +# Message pub/sub +# PUB_ENDPOINT=tcp://127.0.0.1: +# SUB_ENDPOINT=tcp://127.0.0.1: +# +PUB_ENDPOINT="tcp://127.0.0.1:@MESSAGE_BROKER_SUB_PORT@" +SUB_ENDPOINT="tcp://127.0.0.1:@MESSAGE_BROKER_PUB_PORT@" + # ----------------------------------------------------------------------------- # The following are for simulated ARC controllers ARCSIM_NUMDEV=0 diff --git a/camerad/CMakeLists.txt b/camerad/CMakeLists.txt index 037051be..f4fcc0a0 100644 --- a/camerad/CMakeLists.txt +++ b/camerad/CMakeLists.txt @@ -99,6 +99,11 @@ target_include_directories(${INTERFACE_TARGET} PUBLIC ${INTERFACE_INCLUDES}) find_library(CCFITS_LIB CCfits NAMES libCCfits PATHS /usr/local/lib) find_library(CFITS_LIB cfitsio NAMES libcfitsio PATHS /usr/local/lib) +# ZeroMQ +# +find_library( ZMQPP_LIB zmqpp NAMES libzmqpp PATHS /usr/local/lib ) +find_library( ZMQ_LIB zmq NAMES libzmq PATHS /usr/local/lib ) + find_package(Threads) add_library(camera STATIC @@ -124,6 +129,8 @@ target_link_libraries(camerad ${CMAKE_THREAD_LIBS_INIT} ${CCFITS_LIB} ${CFITS_LIB} + ${ZMQPP_LIB} + ${ZMQ_LIB} ) # ---------------------------------------------------------------------------- diff --git a/camerad/astrocam.cpp b/camerad/astrocam.cpp index 0118e80a..2c2e6655 100644 --- a/camerad/astrocam.cpp +++ b/camerad/astrocam.cpp @@ -16,6 +16,10 @@ extern Camera::Server server; namespace AstroCam { + void Interface::publish_snapshot() { + } + + long NewAstroCam::new_expose( std::string nseq_in ) { logwrite( "NewAstroCam::new_expose", nseq_in ); return( NO_ERROR ); @@ -187,52 +191,6 @@ namespace AstroCam { /***** AstroCam::Callback::ftCallback ***************************************/ - /***** AstroCam::Interface::Interface ***************************************/ - /** - * @brief AstroCam Interface class constructor - * - */ - Interface::Interface() { - this->state_monitor_thread_running = false; - this->modeselected = false; - this->pci_cmd_num.store(0); - this->nexp.store(1); - this->numdev = 0; - this->nframes = 1; - this->nfpseq = 1; - this->useframes = true; - this->framethreadcount = 0; - - this->pFits.resize( NUM_EXPBUF ); // pre-allocate FITS_file object pointers for each exposure buffer - this->fitsinfo.resize( NUM_EXPBUF ); // pre-allocate Camera Information object pointers for each exposure buffer - - this->writes_pending.resize( NUM_EXPBUF ); // pre-allocate writes_pending vector for each exposure buffer - - // Initialize STL map of Readout Amplifiers - // Indexed by amplifier name. - // The number is the argument for the Arc command to set this amplifier in the firmware. - // - // Format here is: { AMP_NAME, { ENUM_TYPE, ARC_ARG } } - // where AMP_NAME is the name of the readout amplifier, the index for this map - // ENUM_TYPE is an enum of type ReadoutType - // ARC_ARG is the ARC argument for the SOS command to select this readout source - // - this->readout_source.insert( { "U1", { U1, 0x5f5531 } } ); // "_U1" - this->readout_source.insert( { "L1", { L1, 0x5f4c31 } } ); // "_L1" - this->readout_source.insert( { "U2", { U2, 0x5f5532 } } ); // "_U2" - this->readout_source.insert( { "L2", { L2, 0x5f4c32 } } ); // "_L2" - this->readout_source.insert( { "SPLIT1", { SPLIT1, 0x5f5f31 } } ); // "__1" - this->readout_source.insert( { "SPLIT2", { SPLIT2, 0x5f5f32 } } ); // "__2" - this->readout_source.insert( { "QUAD", { QUAD, 0x414c4c } } ); // "ALL" - this->readout_source.insert( { "FT2", { FT2, 0x465432 } } ); // "FT2" -- frame transfer from 1->2, read split2 - this->readout_source.insert( { "FT1", { FT1, 0x465431 } } ); // "FT1" -- frame transfer from 2->1, read split1 -// this->readout_source.insert( { "hawaii1", { HAWAII_1CH, 0xffffff } } ); ///< TODO @todo implement HxRG 1 channel deinterlacing -// this->readout_source.insert( { "hawaii32", { HAWAII_32CH, 0xffffff } } ); ///< TODO @todo implement HxRG 32 channel deinterlacing -// this->readout_source.insert( { "hawaii32lr", { HAWAII_32CH_LR, 0xffffff } } ); ///< TODO @todo implement HxRG 32 channel alternate left/right deinterlacing - } - /***** AstroCam::Interface::Interface ***************************************/ - - /***** AstroCam::Interface::interface ***************************************/ /** * @brief returns the interface diff --git a/camerad/astrocam.h b/camerad/astrocam.h index 19fdcd22..c275fb2e 100644 --- a/camerad/astrocam.h +++ b/camerad/astrocam.h @@ -19,6 +19,8 @@ #include #include #include +#include +#include #include "utilities.h" #include "common.h" @@ -562,6 +564,7 @@ namespace AstroCam { */ class Interface : public Camera::InterfaceBase { private: + zmqpp::context context; // int bufsize; int FITS_STRING_KEY; int FITS_DOUBLE_KEY; @@ -618,7 +621,42 @@ namespace AstroCam { } public: - Interface(); + Interface() + : context(), + pci_cmd_num(0), + nexp(1), + nfpseq(1), + nframes(1), + numdev(0), + is_subscriber_thread_running(false), + should_subscriber_thread_run(false), + framethreadcount(0), + state_monitor_thread_running(false), + modeselected(false), + useframes(true) { + this->pFits.resize( NUM_EXPBUF ); // pre-allocate FITS_file object pointers for each exposure buffer + this->fitsinfo.resize( NUM_EXPBUF ); // pre-allocate Camera Information object pointers for each exposure buffer + this->writes_pending.resize( NUM_EXPBUF ); // pre-allocate writes_pending vector for each exposure buffer + + // Initialize STL map of Readout Amplifiers + // Indexed by amplifier name. + // The number is the argument for the Arc command to set this amplifier in the firmware. + // + // Format here is: { AMP_NAME, { ENUM_TYPE, ARC_ARG } } + // where AMP_NAME is the name of the readout amplifier, the index for this map + // ENUM_TYPE is an enum of type ReadoutType + // ARC_ARG is the ARC argument for the SOS command to select this readout source + // + this->readout_source.insert( { "U1", { U1, 0x5f5531 } } ); // "_U1" + this->readout_source.insert( { "L1", { L1, 0x5f4c31 } } ); // "_L1" + this->readout_source.insert( { "U2", { U2, 0x5f5532 } } ); // "_U2" + this->readout_source.insert( { "L2", { L2, 0x5f4c32 } } ); // "_L2" + this->readout_source.insert( { "SPLIT1", { SPLIT1, 0x5f5f31 } } ); // "__1" + this->readout_source.insert( { "SPLIT2", { SPLIT2, 0x5f5f32 } } ); // "__2" + this->readout_source.insert( { "QUAD", { QUAD, 0x414c4c } } ); // "ALL" + this->readout_source.insert( { "FT2", { FT2, 0x465432 } } ); // "FT2" -- frame transfer from 1->2, read split2 + this->readout_source.insert( { "FT1", { FT1, 0x465431 } } ); // "FT1" -- frame transfer from 2->1, read split1 + } ; // Class Objects // @@ -626,6 +664,25 @@ namespace AstroCam { Camera::Camera camera; /// instantiate a Camera object Camera::Information camera_info; /// this is the main camera_info object + std::unique_ptr publisher; ///< publisher object + std::string publisher_address; ///< publish socket endpoint + std::string publisher_topic; ///< my default topic for publishing + std::unique_ptr subscriber; ///< subscriber object + std::string subscriber_address; ///< subscribe socket endpoint + std::vector subscriber_topics; ///< list of topics I subscribe to + std::atomic is_subscriber_thread_running; ///< is my subscriber thread running? + std::atomic should_subscriber_thread_run; ///< should my subscriber thread run? + std::unordered_map> topic_handlers; + ///< maps a handler function to each topic + + long init_pubsub(const std::initializer_list &topics={}) { + return Common::PubSubHandler::init_pubsub(context, *this, topics); + } + void start_subscriber_thread() { Common::PubSubHandler::start_subscriber_thread(*this); } + void stop_subscriber_thread() { Common::PubSubHandler::stop_subscriber_thread(*this); } + void publish_snapshot(); + // vector of pointers to Camera Information containers, one for each exposure number // std::vector> fitsinfo; diff --git a/camerad/camerad.cpp b/camerad/camerad.cpp index 7425ce58..bb6509b6 100644 --- a/camerad/camerad.cpp +++ b/camerad/camerad.cpp @@ -178,6 +178,14 @@ int main(int argc, char **argv) { server.exit_cleanly(); } + if (server.init_pubsub()==ERROR) { + logwrite(function, "ERROR initializing publisher-subscriber handler"); + server.exit_cleanly(); + } + + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + server.publish_snapshot(); + // This will pre-thread N_THREADS threads. // The 0th thread is reserved for the blocking port, and the rest are for the non-blocking port. // Each thread gets a socket object. All of the socket objects are stored in a vector container. From ddbece578b50136f441549db7288ce9d79b5fda5 Mon Sep 17 00:00:00 2001 From: David Hale Date: Mon, 2 Feb 2026 08:33:23 -0800 Subject: [PATCH 12/74] more to minimum pubsub --- camerad/camerad.h | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/camerad/camerad.h b/camerad/camerad.h index f63fc4b8..d38dcde8 100644 --- a/camerad/camerad.h +++ b/camerad/camerad.h @@ -223,6 +223,23 @@ namespace Camera { applied++; } + // PUB_ENDPOINT + // + if (config.param[entry]=="PUB_ENDPOINT") { + this->publisher_address=config.arg[entry]; + this->publisher_topic=DAEMON_NAME; + this->camera.async.enqueue_and_log("CAMERAD", function, "CAMERAD:config:"+config.param[entry]+"="+config.arg[entry]); + applied++; + } + + // SUB_ENDPOINT + // + if (config.param[entry]=="SUB_ENDPOINT") { + this->subscriber_address=config.arg[entry]; + this->camera.async.enqueue_and_log("CAMERAD", function, "CAMERAD:config:"+config.param[entry]+"="+config.arg[entry]); + applied++; + } + // USERKEYS_PERSIST: should userkeys persist or be cleared after each exposure // if ( config.param[entry] == "USERKEYS_PERSIST" ) { From f96c6736483212ada5c96f0247c4b184d875f551 Mon Sep 17 00:00:00 2001 From: David Hale Date: Mon, 2 Feb 2026 11:02:01 -0800 Subject: [PATCH 13/74] bug fix AstroCam::Interface::init_pubsub --- camerad/astrocam.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/camerad/astrocam.h b/camerad/astrocam.h index c275fb2e..d024a3f4 100644 --- a/camerad/astrocam.h +++ b/camerad/astrocam.h @@ -677,6 +677,9 @@ namespace AstroCam { ///< maps a handler function to each topic long init_pubsub(const std::initializer_list &topics={}) { + if (!subscriber) { + subscriber = std::make_unique(context, Common::PubSub::Mode::SUB); + } return Common::PubSubHandler::init_pubsub(context, *this, topics); } void start_subscriber_thread() { Common::PubSubHandler::start_subscriber_thread(*this); } From 9d1eaf322ab62697831b39ac5705deabd06db9cb Mon Sep 17 00:00:00 2001 From: David Hale Date: Mon, 2 Feb 2026 14:01:26 -0800 Subject: [PATCH 14/74] camerad now uses ZMQ to properly publish "is exposure ready" and sequencerd is subscribing to that and waiting on that to start an exposure --- camerad/astrocam.cpp | 35 ++++++++++++++++++++++++++++++++++- camerad/astrocam.h | 7 +++++-- camerad/camerad.cpp | 15 +++++++++++++++ common/message_keys.h | 26 ++++++++++++++++++++++++++ sequencerd/sequence.cpp | 38 ++++++++++++++++++++++++++++++++++++++ sequencerd/sequence.h | 12 ++++++++++-- 6 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 common/message_keys.h diff --git a/camerad/astrocam.cpp b/camerad/astrocam.cpp index 2c2e6655..8e51b24d 100644 --- a/camerad/astrocam.cpp +++ b/camerad/astrocam.cpp @@ -12,12 +12,42 @@ */ #include "camerad.h" +#include "message_keys.h" + extern Camera::Server server; namespace AstroCam { - void Interface::publish_snapshot() { + /**** AstroCam::Interface::publish_snapshot *********************************/ + /** + * @brief publish a snapshot of my telemetry + * @param[out] retstring optional pointer to buffer for return string + * + */ + void Interface::publish_snapshot(std::string *retstring) { + const std::string function("AstroCam::Interface::publish_snapshot"); + nlohmann::json jmessage_out; + + // build JSON message with my telemetry + jmessage_out[Key::SOURCE] = "camerad"; + jmessage_out[Key::Camerad::READY] = this->is_exposure_ready.load(); + + // publish JSON message + try { + this->publisher->publish(jmessage_out); + } + catch (const std::exception &e) { + logwrite(function, "ERROR: "+std::string(e.what())); + return; + } + + // if a retstring buffer was supplied then return the JSON message + if (retstring) { + *retstring=jmessage_out.dump(); + retstring->append(JEOF); + } } + /**** AstroCam::Interface::publish_snapshot *********************************/ long NewAstroCam::new_expose( std::string nseq_in ) { @@ -2518,6 +2548,7 @@ namespace AstroCam { // Log this message once only // if ( interface.exposure_pending() ) { + interface.is_exposure_ready.store(false); interface.camera.async.enqueue_and_log( function, "NOTICE:exposure pending" ); interface.camera.async.enqueue( "CAMERAD:READY:false" ); } @@ -2549,6 +2580,8 @@ namespace AstroCam { interface.do_expose(interface.nexp); } else { + interface.is_exposure_ready.store(true); + interface.publish_snapshot(); interface.camera.async.enqueue_and_log( function, "NOTICE:ready for next exposure" ); interface.camera.async.enqueue( "CAMERAD:READY:true" ); } diff --git a/camerad/astrocam.h b/camerad/astrocam.h index d024a3f4..185f065b 100644 --- a/camerad/astrocam.h +++ b/camerad/astrocam.h @@ -21,6 +21,7 @@ #include #include #include +#include #include "utilities.h" #include "common.h" @@ -632,6 +633,7 @@ namespace AstroCam { should_subscriber_thread_run(false), framethreadcount(0), state_monitor_thread_running(false), + is_exposure_ready(true), // am I ready for the next exposure? modeselected(false), useframes(true) { this->pFits.resize( NUM_EXPBUF ); // pre-allocate FITS_file object pointers for each exposure buffer @@ -656,7 +658,7 @@ namespace AstroCam { this->readout_source.insert( { "QUAD", { QUAD, 0x414c4c } } ); // "ALL" this->readout_source.insert( { "FT2", { FT2, 0x465432 } } ); // "FT2" -- frame transfer from 1->2, read split2 this->readout_source.insert( { "FT1", { FT1, 0x465431 } } ); // "FT1" -- frame transfer from 2->1, read split1 - } ; + }; // Class Objects // @@ -684,7 +686,7 @@ namespace AstroCam { } void start_subscriber_thread() { Common::PubSubHandler::start_subscriber_thread(*this); } void stop_subscriber_thread() { Common::PubSubHandler::stop_subscriber_thread(*this); } - void publish_snapshot(); + void publish_snapshot(std::string* retstring=nullptr); // vector of pointers to Camera Information containers, one for each exposure number // @@ -783,6 +785,7 @@ std::vector> fitsinfo; * exposure pending stuff * */ + std::atomic is_exposure_ready; std::condition_variable exposure_condition; std::mutex exposure_lock; static void dothread_monitor_exposure_pending( Interface &interface ); diff --git a/camerad/camerad.cpp b/camerad/camerad.cpp index bb6509b6..d995bf9a 100644 --- a/camerad/camerad.cpp +++ b/camerad/camerad.cpp @@ -789,6 +789,21 @@ void doit(Network::TcpSocket &sock) { if ( cmd == CAMERAD_TEST ) { ret = server.test(args, retstring); } + else + if ( cmd == SNAPSHOT || cmd == TELEMREQUEST ) { + if ( args=="?" || args=="help" ) { + retstring=TELEMREQUEST+"\n"; + retstring.append( " Returns a serialized JSON message containing telemetry\n" ); + retstring.append( " information, terminated with \"EOF\\n\".\n" ); + ret=HELP; + } + else { + server.publish_snapshot( &retstring ); + if (retstring.empty()) retstring="(empty)"; + ret = JSON; + } + } + // Unknown commands generate an error // else { diff --git a/common/message_keys.h b/common/message_keys.h new file mode 100644 index 00000000..0155cc8c --- /dev/null +++ b/common/message_keys.h @@ -0,0 +1,26 @@ +/** + * @file message_keys.h + * @brief contains keys for JSON messages + * @author David Hale + * + */ +#pragma once + +#include + +namespace Topic { + inline const std::string SNAPSHOT = "_snapshot"; + inline const std::string TCSD = "tcsd"; + inline const std::string TARGETINFO = "tcsd"; + inline const std::string SLITD = "slitd"; + inline const std::string CAMERAD = "camerad"; +} + +namespace Key { + + inline const std::string SOURCE = "source"; + + namespace Camerad { + inline const std::string READY = "ready"; + } +} diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index f321c6a7..f1e1bf2f 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -12,6 +12,7 @@ */ #include "sequence.h" +#include "message_keys.h" namespace Sequencer { @@ -40,6 +41,23 @@ namespace Sequencer { /***** Sequencer::Sequence::handletopic_snapshot ***************************/ + /***** Sequencer::Sequence::handletopic_camerad ****************************/ + /** + * @brief handles camerad telemetry + * @param[in] jmessage subscribed-received JSON message + * + */ + void Sequence::handletopic_camerad(const nlohmann::json &jmessage) { + if (jmessage.contains(Key::Camerad::READY)) { + int isready = jmessage[Key::Camerad::READY].get(); + this->is_camera_ready.store(isready, std::memory_order_relaxed); + std::lock_guard lock(camerad_mtx); + this->camerad_cv.notify_all(); + } + } + /***** Sequencer::Sequence::handletopic_camerad ****************************/ + + /***** Sequencer::Sequence::publish_snapshot *******************************/ /** * @brief publishes snapshot of my telemetry @@ -417,6 +435,26 @@ namespace Sequencer { logwrite( function, "sequencer running" ); + // wait until camera is ready to expose + // + if (!this->is_camera_ready.load()) { + + this->async.enqueue_and_log(function, "NOTICE: waiting for camera to be ready to expose"); + + std::unique_lock lock(this->camerad_mtx); + this->camerad_cv.wait( lock, [this]() { + return( this->is_camera_ready.load() || this->cancel_flag.load() ); + } ); + + if (this->cancel_flag.load()) { + logwrite(function, "sequence cancelled"); + return; + } + else { + logwrite(function, "camera ready to expose"); + } + } + // Get the next target from the database when single_obsid is empty // if ( this->single_obsid.empty() ) { diff --git a/sequencerd/sequence.h b/sequencerd/sequence.h index 8703c1d6..1bc3c65e 100644 --- a/sequencerd/sequence.h +++ b/sequencerd/sequence.h @@ -33,6 +33,7 @@ #include "slitd_commands.h" #include "tcsd_commands.h" #include "sequencerd_commands.h" +#include "message_keys.h" #include "tcs_constants.h" #include "acam_interface_shared.h" @@ -280,6 +281,7 @@ namespace Sequencer { private: zmqpp::context context; bool ready_to_start; ///< set on nightly startup success, used to return seqstate to READY after an abort + std::atomic is_camera_ready; std::atomic is_science_frame_transfer; ///< is frame transfer enabled for science cameras std::atomic notify_tcs_next_target; ///< notify TCS of next target when remaining time within TCS_PREAUTH_TIME std::atomic arm_readout_flag; ///< @@ -307,6 +309,7 @@ namespace Sequencer { Sequence() : context(), ready_to_start(false), + is_camera_ready(false), is_science_frame_transfer(false), notify_tcs_next_target(false), arm_readout_flag(false), @@ -334,8 +337,10 @@ namespace Sequencer { daemon_manager.set_callback([this](const std::bitset& states) { broadcast_daemonstate(); }); topic_handlers = { - { "_snapshot", std::function( - [this](const nlohmann::json &msg) { handletopic_snapshot(msg); } ) } + { Topic::SNAPSHOT, std::function( + [this](const nlohmann::json &msg) { handletopic_snapshot(msg); } ) }, + { Topic::CAMERAD, std::function( + [this](const nlohmann::json &msg) { handletopic_camerad(msg); } ) } }; } @@ -379,6 +384,8 @@ namespace Sequencer { /// std::mutex tcs_ontarget_mtx; /// std::condition_variable tcs_ontarget_cv; + std::mutex camerad_mtx; + std::condition_variable camerad_cv; std::mutex wait_mtx; std::condition_variable cv; std::mutex cv_mutex; @@ -448,6 +455,7 @@ namespace Sequencer { void stop_subscriber_thread() { Common::PubSubHandler::stop_subscriber_thread(*this); } void handletopic_snapshot( const nlohmann::json &jmessage ); + void handletopic_camerad( const nlohmann::json &jmessage ); void publish_snapshot(); void publish_snapshot(std::string &retstring); void publish_seqstate(); From 780ef7fd38dae32fad4c561fc45d04cb4663183b Mon Sep 17 00:00:00 2001 From: David Hale Date: Mon, 2 Feb 2026 15:31:46 -0800 Subject: [PATCH 15/74] restore Config files from main branch --- Config/flexured.cfg.in | 8 ++++---- Config/focusd.cfg.in | 10 +++++----- Config/sequencerd.cfg.in | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Config/flexured.cfg.in b/Config/flexured.cfg.in index 3b2aaa86..c71cbe23 100644 --- a/Config/flexured.cfg.in +++ b/Config/flexured.cfg.in @@ -35,11 +35,11 @@ MOTOR_CONTROLLER="I @IP_FLEXURE_I@ 50000 1 3" # 2 = X = spectral # 3 = Y = spatial # -MOTOR_AXIS="U 1 25 225 0 na" -MOTOR_AXIS="U 2 -1000 1000 0 na" -MOTOR_AXIS="U 3 -1000 1000 0 na" +MOTOR_AXIS="U 1 25 225 0 na 100.0" +MOTOR_AXIS="U 2 -1000 1000 0 na 0.0" +MOTOR_AXIS="U 3 -1000 1000 0 na 0.0" -MOTOR_AXIS="G 1 25 225 0 na 0.0" +MOTOR_AXIS="G 1 25 225 0 na 100.0" MOTOR_AXIS="G 2 -1000 1000 0 na 0.0" MOTOR_AXIS="G 3 -1000 1000 0 na 0.0" diff --git a/Config/focusd.cfg.in b/Config/focusd.cfg.in index 72d9d1a1..81212e20 100644 --- a/Config/focusd.cfg.in +++ b/Config/focusd.cfg.in @@ -30,7 +30,7 @@ EMULATOR_PORT=@FOCUSD_EMULATOR@ MOTOR_CONTROLLER="I @IP_ETS8P@ @ETS8P_FOCUS@ 1 1" MOTOR_CONTROLLER="R @IP_ETS8P@ @ETS8P_FOCUS@ 2 1" MOTOR_CONTROLLER="G @IP_ETS8P@ @ETS8P_FOCUS@ 3 1" -MOTOR_CONTROLLER="U @IP_ETS8P@ @ETS8P_FOCUS@ 4 1" +#MOTOR_CONTROLLER="U @IP_ETS8P@ @ETS8P_FOCUS@ 4 1" #MOTOR_CONTROLLER="I localhost @FOCUSD_EMULATOR@ 1 1" # emulator #MOTOR_CONTROLLER="R localhost @FOCUSD_EMULATOR@ 2 1" # emulator #MOTOR_CONTROLLER="G localhost @FOCUSD_EMULATOR@ 3 1" # emulator @@ -42,8 +42,8 @@ MOTOR_CONTROLLER="U @IP_ETS8P@ @ETS8P_FOCUS@ 4 1" # MOTOR_AXIS="I 1 0 7.6 0 ref 4.75" MOTOR_AXIS="R 1 0 6.7 0 ref 2.45" -MOTOR_AXIS="G 1 0 7.8 0 ref 3.20" -MOTOR_AXIS="U 1 0 7.8 0 ref 0.00" +MOTOR_AXIS="G 1 0 7.8 0 ref 3.35" +#MOTOR_AXIS="U 1 0 7.8 0 ref 0.00" # Specify nominal focus positions for each actuator # MOTOR_POS=" " @@ -55,5 +55,5 @@ MOTOR_AXIS="U 1 0 7.8 0 ref 0.00" # MOTOR_POS="I 1 0 4.75 nominal" MOTOR_POS="R 1 0 2.45 nominal" -MOTOR_POS="G 1 0 3.20 nominal" -MOTOR_POS="U 1 0 0.00 nominal" +MOTOR_POS="G 1 0 3.35 nominal" +#MOTOR_POS="U 1 0 0.00 nominal" diff --git a/Config/sequencerd.cfg.in b/Config/sequencerd.cfg.in index 4592f094..3a5a6901 100644 --- a/Config/sequencerd.cfg.in +++ b/Config/sequencerd.cfg.in @@ -120,12 +120,12 @@ VIRTUAL_SLITO_EXPOSE=3.0 # slit offset for science exposure # The names used here must have a matching NPS_PLUG name defined in power.cfg # POWER_SLIT=SLIT -POWER_CAMERA=(SHUTTER LEACH_R LEACH_I LEACH_U) +POWER_CAMERA=(SHUTTER LEACH_R LEACH_I LEACH_U LEACH_G) POWER_CALIB=(CAL_MOT LAMP_MOD) POWER_LAMP=(LAMPTHAR LAMPFEAR LAMPBLUC LAMPREDC) -POWER_FLEXURE=(FLEX_I FLEX_R) +POWER_FLEXURE=(FLEX_I FLEX_R FLEX_U FLEX_G) POWER_FILTER=ACAM_MOT -POWER_FOCUS=(FOCUS_IR) +POWER_FOCUS=(FOCUS_IR FOCUS_G FOCUS_U) POWER_TELEM=CR1000 POWER_THERMAL=(LKS_IG LKS_UR CR1000) POWER_ACAM=(ACAM ACAM_MOT) From 43f39d831e6d0cb49d51cc12a05d5994a58293c1 Mon Sep 17 00:00:00 2001 From: David Hale Date: Mon, 2 Feb 2026 16:04:34 -0800 Subject: [PATCH 16/74] fixes bug in Sequencer::CalibrationTarget::configure --- sequencerd/sequencer_interface.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sequencerd/sequencer_interface.cpp b/sequencerd/sequencer_interface.cpp index eecf09c8..810feb63 100644 --- a/sequencerd/sequencer_interface.cpp +++ b/sequencerd/sequencer_interface.cpp @@ -997,12 +997,12 @@ namespace Sequencer { // tokens 11-12 are dome lamps for (size_t i=0; i < 2; i++) { - info.domelamp.at(i) = on_off(tokens.at(11+i)); + info.domelamp[i] = on_off(tokens.at(11+i)); } // tokens 13-19 for (size_t i=0; i<6; i++) { - info.lampmod[i] = on_off(tokens.at(13+1)); + info.lampmod[i] = on_off(tokens.at(13+i)); } } catch (const std::exception &e) { From 63a7ef3570a910b65a13685dac11504565128bad Mon Sep 17 00:00:00 2001 From: David Hale Date: Mon, 2 Feb 2026 17:35:01 -0800 Subject: [PATCH 17/74] fixes deadlock and sequencer subscribes to camerad --- camerad/astrocam.cpp | 18 +++++++++++++++++ sequencerd/sequence.cpp | 41 ++++++++++++++++++++------------------- sequencerd/sequencerd.cpp | 2 +- 3 files changed, 40 insertions(+), 21 deletions(-) diff --git a/camerad/astrocam.cpp b/camerad/astrocam.cpp index 8e51b24d..cc23a70d 100644 --- a/camerad/astrocam.cpp +++ b/camerad/astrocam.cpp @@ -1251,6 +1251,8 @@ namespace AstroCam { error = ERROR; } + this->publish_snapshot(); + return( error ); } /***** AstroCam::Interface::do_connect_controller ***************************/ @@ -2549,6 +2551,7 @@ namespace AstroCam { // if ( interface.exposure_pending() ) { interface.is_exposure_ready.store(false); + interface.publish_snapshot(); interface.camera.async.enqueue_and_log( function, "NOTICE:exposure pending" ); interface.camera.async.enqueue( "CAMERAD:READY:false" ); } @@ -6029,6 +6032,7 @@ logwrite(function, message.str()); retstring.append( " shdelay ? | | test\n" ); retstring.append( " shutter ? | init | open | close | get | time | expose \n" ); retstring.append( " telem ? | collect | test | calibd | flexured | focusd | tcsd\n" ); + retstring.append( " isready\n" ); retstring.append( " isreadout\n" ); retstring.append( " pixelcount\n" ); retstring.append( " devnums\n" ); @@ -6384,6 +6388,10 @@ logwrite(function, message.str()); logwrite( function, message.str() ); retstring.append( message.str() ); retstring.append( "\n" ); + message.str(""); message << "is_exposure_ready=" << ( this->is_exposure_ready.load() ? "true" : "false" ); + logwrite( function, message.str() ); + retstring.append( message.str() ); retstring.append( "\n" ); + // this shows which channels have an exposure pending { std::vector pending = this->exposure_pending_list(); @@ -6600,6 +6608,16 @@ logwrite(function, message.str()); } else // ---------------------------------------------------- + // isready + // ---------------------------------------------------- + // am I ready for an exposure? + if (testname=="isready") { + retstring=(this->is_exposure_ready?"yes":"no"); + logwrite(function, retstring); + return NO_ERROR; + } + else + // ---------------------------------------------------- // isreadout // ---------------------------------------------------- // call ARC API isReadout() function directly diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index f1e1bf2f..943a0547 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -435,26 +435,6 @@ namespace Sequencer { logwrite( function, "sequencer running" ); - // wait until camera is ready to expose - // - if (!this->is_camera_ready.load()) { - - this->async.enqueue_and_log(function, "NOTICE: waiting for camera to be ready to expose"); - - std::unique_lock lock(this->camerad_mtx); - this->camerad_cv.wait( lock, [this]() { - return( this->is_camera_ready.load() || this->cancel_flag.load() ); - } ); - - if (this->cancel_flag.load()) { - logwrite(function, "sequence cancelled"); - return; - } - else { - logwrite(function, "camera ready to expose"); - } - } - // Get the next target from the database when single_obsid is empty // if ( this->single_obsid.empty() ) { @@ -761,6 +741,23 @@ namespace Sequencer { std::stringstream camcmd; long error=NO_ERROR; + // wait until camera is ready to expose + // + std::unique_lock lock(this->camerad_mtx); + if (!this->is_camera_ready.load()) { + + this->async.enqueue_and_log(function, "NOTICE: waiting for camera to be ready to expose"); + + this->camerad_cv.wait( lock, [this]() { + return( this->is_camera_ready.load() || this->cancel_flag.load() ); + } ); + + if (this->cancel_flag.load()) { + logwrite(function, "sequence cancelled"); + return NO_ERROR; + } + } + logwrite( function, "setting camera parameters"); ScopedState thr_state( thread_state_manager, Sequencer::THR_CAMERA_SET ); @@ -4014,6 +4011,10 @@ namespace Sequencer { message.str(""); message << "NOTICE: daemons not ready: " << this->daemon_manager.get_cleared_states(); this->async.enqueue_and_log( function, message.str() ); + retstring.append( message.str() ); retstring.append( "\n" ); + + message.str(""); message << "NOTICE: camera ready to expose: " << (this->is_camera_ready.load() ? "yes" : "no"); + this->async.enqueue_and_log( function, message.str() ); retstring.append( message.str() ); error = NO_ERROR; diff --git a/sequencerd/sequencerd.cpp b/sequencerd/sequencerd.cpp index a447ca09..841bb4f0 100644 --- a/sequencerd/sequencerd.cpp +++ b/sequencerd/sequencerd.cpp @@ -129,7 +129,7 @@ int main(int argc, char **argv) { // initialize the pub/sub handler // - if ( sequencerd.sequence.init_pubsub() == ERROR ) { + if ( sequencerd.sequence.init_pubsub( {"camerad"} ) == ERROR ) { logwrite(function, "ERROR initializing publisher-subscriber handler"); sequencerd.exit_cleanly(); } From 4f5a991d1057e7d11873f6c09b5597f3a95326b4 Mon Sep 17 00:00:00 2001 From: David Hale Date: Tue, 3 Feb 2026 08:55:10 -0800 Subject: [PATCH 18/74] unify can_expose variable between camerad-sequencerd --- camerad/astrocam.cpp | 14 +++++++------- camerad/astrocam.h | 4 ++-- sequencerd/sequence.cpp | 8 ++++---- sequencerd/sequence.h | 4 ++-- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/camerad/astrocam.cpp b/camerad/astrocam.cpp index cc23a70d..9fde63c2 100644 --- a/camerad/astrocam.cpp +++ b/camerad/astrocam.cpp @@ -30,7 +30,7 @@ namespace AstroCam { // build JSON message with my telemetry jmessage_out[Key::SOURCE] = "camerad"; - jmessage_out[Key::Camerad::READY] = this->is_exposure_ready.load(); + jmessage_out[Key::Camerad::READY] = this->can_expose.load(); // publish JSON message try { @@ -2550,7 +2550,7 @@ namespace AstroCam { // Log this message once only // if ( interface.exposure_pending() ) { - interface.is_exposure_ready.store(false); + interface.can_expose.store(false); interface.publish_snapshot(); interface.camera.async.enqueue_and_log( function, "NOTICE:exposure pending" ); interface.camera.async.enqueue( "CAMERAD:READY:false" ); @@ -2583,7 +2583,7 @@ namespace AstroCam { interface.do_expose(interface.nexp); } else { - interface.is_exposure_ready.store(true); + interface.can_expose.store(true); interface.publish_snapshot(); interface.camera.async.enqueue_and_log( function, "NOTICE:ready for next exposure" ); interface.camera.async.enqueue( "CAMERAD:READY:true" ); @@ -6032,7 +6032,7 @@ logwrite(function, message.str()); retstring.append( " shdelay ? | | test\n" ); retstring.append( " shutter ? | init | open | close | get | time | expose \n" ); retstring.append( " telem ? | collect | test | calibd | flexured | focusd | tcsd\n" ); - retstring.append( " isready\n" ); + retstring.append( " canexpose\n" ); retstring.append( " isreadout\n" ); retstring.append( " pixelcount\n" ); retstring.append( " devnums\n" ); @@ -6388,7 +6388,7 @@ logwrite(function, message.str()); logwrite( function, message.str() ); retstring.append( message.str() ); retstring.append( "\n" ); - message.str(""); message << "is_exposure_ready=" << ( this->is_exposure_ready.load() ? "true" : "false" ); + message.str(""); message << "can_expose=" << ( this->can_expose.load() ? "true" : "false" ); logwrite( function, message.str() ); retstring.append( message.str() ); retstring.append( "\n" ); @@ -6611,8 +6611,8 @@ logwrite(function, message.str()); // isready // ---------------------------------------------------- // am I ready for an exposure? - if (testname=="isready") { - retstring=(this->is_exposure_ready?"yes":"no"); + if (testname=="canexpose") { + retstring=(this->can_expose?"yes":"no"); logwrite(function, retstring); return NO_ERROR; } diff --git a/camerad/astrocam.h b/camerad/astrocam.h index 185f065b..05999020 100644 --- a/camerad/astrocam.h +++ b/camerad/astrocam.h @@ -633,7 +633,7 @@ namespace AstroCam { should_subscriber_thread_run(false), framethreadcount(0), state_monitor_thread_running(false), - is_exposure_ready(true), // am I ready for the next exposure? + can_expose(true), // am I ready for the next exposure? modeselected(false), useframes(true) { this->pFits.resize( NUM_EXPBUF ); // pre-allocate FITS_file object pointers for each exposure buffer @@ -785,7 +785,7 @@ std::vector> fitsinfo; * exposure pending stuff * */ - std::atomic is_exposure_ready; + std::atomic can_expose; std::condition_variable exposure_condition; std::mutex exposure_lock; static void dothread_monitor_exposure_pending( Interface &interface ); diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index 943a0547..f6de3e1a 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -50,7 +50,7 @@ namespace Sequencer { void Sequence::handletopic_camerad(const nlohmann::json &jmessage) { if (jmessage.contains(Key::Camerad::READY)) { int isready = jmessage[Key::Camerad::READY].get(); - this->is_camera_ready.store(isready, std::memory_order_relaxed); + this->can_expose.store(isready, std::memory_order_relaxed); std::lock_guard lock(camerad_mtx); this->camerad_cv.notify_all(); } @@ -744,12 +744,12 @@ namespace Sequencer { // wait until camera is ready to expose // std::unique_lock lock(this->camerad_mtx); - if (!this->is_camera_ready.load()) { + if (!this->can_expose.load()) { this->async.enqueue_and_log(function, "NOTICE: waiting for camera to be ready to expose"); this->camerad_cv.wait( lock, [this]() { - return( this->is_camera_ready.load() || this->cancel_flag.load() ); + return( this->can_expose.load() || this->cancel_flag.load() ); } ); if (this->cancel_flag.load()) { @@ -4013,7 +4013,7 @@ namespace Sequencer { this->async.enqueue_and_log( function, message.str() ); retstring.append( message.str() ); retstring.append( "\n" ); - message.str(""); message << "NOTICE: camera ready to expose: " << (this->is_camera_ready.load() ? "yes" : "no"); + message.str(""); message << "NOTICE: camera ready to expose: " << (this->can_expose.load() ? "yes" : "no"); this->async.enqueue_and_log( function, message.str() ); retstring.append( message.str() ); diff --git a/sequencerd/sequence.h b/sequencerd/sequence.h index 1bc3c65e..c931cce2 100644 --- a/sequencerd/sequence.h +++ b/sequencerd/sequence.h @@ -281,7 +281,7 @@ namespace Sequencer { private: zmqpp::context context; bool ready_to_start; ///< set on nightly startup success, used to return seqstate to READY after an abort - std::atomic is_camera_ready; + std::atomic can_expose; std::atomic is_science_frame_transfer; ///< is frame transfer enabled for science cameras std::atomic notify_tcs_next_target; ///< notify TCS of next target when remaining time within TCS_PREAUTH_TIME std::atomic arm_readout_flag; ///< @@ -309,7 +309,7 @@ namespace Sequencer { Sequence() : context(), ready_to_start(false), - is_camera_ready(false), + can_expose(false), is_science_frame_transfer(false), notify_tcs_next_target(false), arm_readout_flag(false), From af3ffa567095c4ec6176edd03f2552f3f136f7ab Mon Sep 17 00:00:00 2001 From: David Hale Date: Tue, 3 Feb 2026 13:33:51 -0800 Subject: [PATCH 19/74] sequencer sends activate states from CAL_ table to camerad updates sequencerd.cfg.in other minor cleanup --- Config/sequencerd.cfg.in | 3 +- camerad/astrocam.cpp | 133 +++++++++++++++++-------------- sequencerd/sequence.cpp | 60 +++++++++----- sequencerd/sequencer_interface.h | 8 +- utils/utilities.h | 2 +- 5 files changed, 120 insertions(+), 86 deletions(-) diff --git a/Config/sequencerd.cfg.in b/Config/sequencerd.cfg.in index 3a5a6901..61d81707 100644 --- a/Config/sequencerd.cfg.in +++ b/Config/sequencerd.cfg.in @@ -178,7 +178,8 @@ CAL_TARGET=(CAL_THAR open close on on on on on on on on off off CAL_TARGET=(CAL_FEAR open close on on on on on on on on off off on off off off off off) CAL_TARGET=(CAL_THAR_UG open close on on off off on on on on off off off off off off off on ) CAL_TARGET=(CAL_FEAR_UG open close on on off off on on on on off off on off off off off off) -CAL_TARGET=(CAL_CONT open close on on on on on on on on off off off off off on off off) +CAL_TARGET=(CAL_CONTR open close on on on on on on on on off off off off off on off off) +CAL_TARGET=(CAL_CONTB open close on on on on on on on on off off off off off off on off) CAL_TARGET=(CAL_DOME close open on on on on off off off off off on off off off off off off) CAL_TARGET=(CAL_DOME_UG close open on on off off off off off off off on off off off off off off) CAL_TARGET=(CAL_BIAS close close on on on on off off off off off off off off off off off off) diff --git a/camerad/astrocam.cpp b/camerad/astrocam.cpp index 9fde63c2..2675ccdb 100644 --- a/camerad/astrocam.cpp +++ b/camerad/astrocam.cpp @@ -639,6 +639,8 @@ namespace AstroCam { /***** AstroCam::Interface::parse_activate_commands *************************/ /** * @brief parses the ACTIVATE_COMMANDS keywords from config file + * @details This gets the list of native commands needed to send when + * (de)activating a controller channel. * @param[in] args expected format is "CHAN CMD [, CMD, CMD, ...]" * */ @@ -5516,85 +5518,98 @@ logwrite(function, message.str()); /***** AstroCam::Interface::camera_active_state *****************************/ /** * @brief set/get camera active state + * @details De-activating a configured channel turns off the biases and + * flagging it for non-use. This allows keeping a controller in + * a sort of standby condition, without having to reload waveforms + * and reconfigure. + * @param[in] args space-delimited list of one or more channel names {U G R I} + * @param[out] retstring activated|deactivated|error + * @param[in] cmd AstroCam::ActiveState:: {Activate|DeActivate|Query} + * @return ERROR|NO_ERROR * */ long Interface::camera_active_state(const std::string &args, std::string &retstring, AstroCam::ActiveState cmd) { const std::string function("AstroCam::Interface::camera_active_state"); - std::string chan; + std::vector _devnums; // local list of devnum(s) associated with chan(s) + std::string chan; // current channel std::istringstream iss(args); - // get channel name from args + // get channel name(s) from args and + // convert to a vector of devnum(s) // - if (!(iss >> chan)) { - logwrite(function, "ERROR parsing args. expected "); - retstring="bad_argument"; - return ERROR; + while (iss >> chan) { + // validate device number for that channel + int dev; + try { + dev = devnum_from_chan(chan); + } + // exceptions are not fatal, just don't add dev to the vector + catch(const std::exception &e) { + logwrite(function, "channel "+chan+": "+std::string(e.what())); + continue; + } + // push it into a vector + _devnums.push_back(dev); } - // get device number for that channel - // - int dev; - try { - dev = devnum_from_chan(chan); - } - catch(const std::exception &e) { - logwrite(function, "ERROR: "+std::string(e.what())); - retstring="bad_channel"; - return ERROR; - } + long error = NO_ERROR; - // get pointer to the Controller object for this device - // it only needs to exist and be connected + retstring.clear(); + + // activate/deactivate each dev // - auto pcontroller = this->get_controller(dev); + for (const auto &dev : _devnums) { + // get pointer to the Controller object for this device + // it only needs to exist and be connected + auto pcontroller = this->get_controller(dev); - if (!pcontroller) { - logwrite(function, "ERROR: channel "+chan+" not configured"); - retstring="missing_device"; - return ERROR; - } - if (!pcontroller->configured || !pcontroller->connected) { - logwrite(function, "ERROR: channel "+chan+" not connected"); - retstring="not_connected"; - return ERROR; - } + // unavailable channels are not fatal, they just don't get used + if (!pcontroller) { + logwrite(function, "channel "+pcontroller->channel+" not configured"); + continue; + } + if (!pcontroller->configured || !pcontroller->connected) { + logwrite(function, "channel "+pcontroller->channel+" not connected"); + continue; + } - long error = NO_ERROR; + // set or get active state as specified by cmd + switch (cmd) { - // set or get active state as specified by cmd - // - switch (cmd) { + // first set active flag, then send activation commands + case AstroCam::ActiveState::Activate: + if (pcontroller->active) break; // nothing to do if already activated + pcontroller->active = true; + // add this devnum to the active_devnums list + add_dev(dev, this->active_devnums); + // send the activation commands + for (const auto &cmd : pcontroller->activate_commands) { + error |= this->do_native(dev, std::string(cmd), retstring); + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + } + break; - // first set active flag, then send activation commands - case AstroCam::ActiveState::Activate: - pcontroller->active = true; - // add this devnum to the active_devnums list - add_dev(dev, this->active_devnums); - // send the activation commands - for (const auto &cmd : pcontroller->activate_commands) { - error |= this->do_native(dev, std::string(cmd), retstring); - std::this_thread::sleep_for(std::chrono::milliseconds(500)); - } - break; + // first turn off power, then clear active flag + case AstroCam::ActiveState::DeActivate: + if (!pcontroller->active) break; // nothing to do if already deactivated + if ( (error=this->do_native(dev, std::string("POF"), retstring))==NO_ERROR ) { + pcontroller->active = false; + // remove this devnum from the active_devnums list + remove_dev(dev, this->active_devnums); + } + break; - // first turn off power, then clear active flag - case AstroCam::ActiveState::DeActivate: - if ( (error=this->do_native(dev, std::string("POF"), retstring))==NO_ERROR ) { - pcontroller->active = false; - // remove this devnum from the active_devnums list - remove_dev(dev, this->active_devnums); - } - break; + // do nothing + case AstroCam::ActiveState::Query: + default: + break; + } - // do nothing - case AstroCam::ActiveState::Query: - default: - break; + // build up return string + retstring += (pcontroller->channel+":"+(pcontroller->active ? "activated " : "deactivated ")); } - retstring = pcontroller->active ? "activated" : "deactivated"; - return error; } /***** AstroCam::Interface::camera_active_state *****************************/ diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index f6de3e1a..2e2f9410 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -765,6 +765,35 @@ namespace Sequencer { this->thread_error_manager.set( THR_CAMERA_SET ); // assume the worse, clear on success + // Controller activate states stored in Sequencer::CalibrationTarget::calinfo map, + // indexed by name. Calibration targets use target.name for the index, or + // use "SCIENCE" index for all science targets. + // + std::ostringstream activechans, deactivechans; + const std::string calname = std::string(this->target.iscal ? this->target.name : "SCIENCE"); + const auto &calinfo = this->caltarget.get_info(calname); + + // build up lists of (de)activate chans + for (const auto &[chan,active] : calinfo.channel_active) { + (active ? activechans : deactivechans) << " " << chan; + } + + // send two commands, one for each + if (!activechans.str().empty()) { + std::string cmd = CAMERAD_ACTIVATE + activechans.str(); + if (this->camerad.send(cmd, reply)!=NO_ERROR) { + this->async.enqueue_and_log(function, "ERROR sending \""+cmd+"\": "+reply); + throw std::runtime_error("camera returned "+reply); + } + } + if (!deactivechans.str().empty()) { + std::string cmd = CAMERAD_DEACTIVATE + deactivechans.str(); + if (this->camerad.send(cmd, reply)!=NO_ERROR) { + this->async.enqueue_and_log(function, "ERROR sending \""+cmd+"\": "+reply); + throw std::runtime_error("camera returned "+reply); + } + } + // send the EXPTIME command to camerad // // Everywhere is maintained that exptime is specified in sec except @@ -2146,34 +2175,21 @@ namespace Sequencer { this->thread_error_manager.set( THR_CALIBRATOR_SET ); // assume the worse, clear on success - // name will index the caltarget map - // - std::string name(this->target.name); - - if ( this->target.iscal ) { - name = this->target.name; - this->async.enqueue_and_log( function, "NOTICE: configuring calibrator for "+name ); - } - else { - this->async.enqueue_and_log( function, "NOTICE: disabling calibrator for science target "+name ); - name="SCIENCE"; // override for indexing the map - } + const std::string calname = std::string(this->target.iscal ? this->target.name : "SCIENCE"); // Get the calibration target map. // This contains a map of all the required settings, indexed by target name. // - auto calinfo = this->caltarget.get_info(name); - if (!calinfo) { - logwrite( function, "ERROR unrecognized calibration target: "+name ); - throw std::runtime_error("unrecognized calibration target: "+name); - } + const auto &calinfo = this->caltarget.get_info(calname); + + this->async.enqueue_and_log(function, "NOTICE: configuring calibrator for "+calname); // set the calib door and cover // std::stringstream cmd; cmd.str(""); cmd << CALIBD_SET - << " door=" << ( calinfo->caldoor ? "open" : "close" ) - << " cover=" << ( calinfo->calcover ? "open" : "close" ); + << " door=" << ( calinfo.caldoor ? "open" : "close" ) + << " cover=" << ( calinfo.calcover ? "open" : "close" ); logwrite( function, "calib: "+cmd.str() ); if ( !this->cancel_flag.load() && @@ -2184,7 +2200,7 @@ namespace Sequencer { // set the internal calibration lamps // - for ( const auto &[lamp,state] : calinfo->lamp ) { + for ( const auto &[lamp,state] : calinfo.lamp ) { if ( this->cancel_flag.load() ) break; cmd.str(""); cmd << lamp << " " << (state?"on":"off"); message.str(""); message << "power " << cmd.str(); @@ -2200,7 +2216,7 @@ namespace Sequencer { // // // set the dome lamps // // -// for ( const auto &[lamp,state] : calinfo->domelamp ) { +// for ( const auto &[lamp,state] : calinfo.domelamp ) { // if ( this->cancel_flag.load() ) break; // cmd.str(""); cmd << TCSD_NATIVE << " NPS " << lamp << " " << (state?1:0); // if ( this->tcsd.command( cmd.str() ) != NO_ERROR ) { @@ -2211,7 +2227,7 @@ namespace Sequencer { // set the lamp modulators // - for ( const auto &[mod,state] : calinfo->lampmod ) { + for ( const auto &[mod,state] : calinfo.lampmod ) { if ( this->cancel_flag.load() ) break; cmd.str(""); cmd << CALIBD_LAMPMOD << " " << mod << " " << (state?1:0) << " 1000"; if ( this->calibd.command( cmd.str() ) != NO_ERROR ) { diff --git a/sequencerd/sequencer_interface.h b/sequencerd/sequencer_interface.h index f3f7f83f..f4569e7c 100644 --- a/sequencerd/sequencer_interface.h +++ b/sequencerd/sequencer_interface.h @@ -154,10 +154,12 @@ namespace Sequencer { const std::unordered_map &getmap() const { return calmap; }; ///< returns just the map contents for specified targetname key - const calinfo_t* get_info( const std::string &_name ) const { + const calinfo_t &get_info( const std::string &_name ) const { auto it = calmap.find(_name); - if ( it != calmap.end() ) return &it->second; - return nullptr; + if ( it == calmap.end() ) { + throw std::runtime_error("calinfo not found for: "+_name); + } + return it->second; } private: diff --git a/utils/utilities.h b/utils/utilities.h index 5308568d..7203c578 100644 --- a/utils/utilities.h +++ b/utils/utilities.h @@ -371,7 +371,7 @@ class BoolState { */ class PreciseTimer { private: - static const long max_short_sleep = 3000000; // units are microseconds + static inline constexpr long max_short_sleep = 3000000; // units are microseconds std::atomic should_hold; std::atomic on_hold; From e8ad93e128a7a9dfc20f2160cec449e8f5bef0a9 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sat, 7 Feb 2026 16:35:04 -0800 Subject: [PATCH 20/74] Add acquisition automation modes with repeat target skip logic - Add acqmode command to control acquisition automation (modes 1-3) - Mode 1: legacy manual mode (wait for user at each step) - Mode 2: semi-automatic (start acquisition, wait for user to expose) - Mode 3: fully automatic (apply offsets automatically) - Add repeat target detection to skip unnecessary slew/acquisition - Add configuration parameters for acquisition automation - Skip acquisition when re-observing same coordinates (modes 2-3) Based on PR #391 acquisition automation features Applied to DH/sequencer-calib-work branch (observatory baseline) --- Config/sequencerd.cfg.in | 4 + common/sequencerd_commands.h | 2 + sequencerd/sequence.cpp | 322 ++++++++++++++++++++++---------- sequencerd/sequence.h | 24 ++- sequencerd/sequencer_server.cpp | 103 ++++++++++ 5 files changed, 343 insertions(+), 112 deletions(-) diff --git a/Config/sequencerd.cfg.in b/Config/sequencerd.cfg.in index 61d81707..d9b0b141 100644 --- a/Config/sequencerd.cfg.in +++ b/Config/sequencerd.cfg.in @@ -160,6 +160,10 @@ ACQUIRE_RETRYS=5 # max number of retrys before acquisition f 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 # the maximum allowable offset sent to the TCS, in arcsec +ACQ_AUTOMATIC_MODE=1 # 1=legacy, 2=semi-auto, 3=auto +ACQ_FINE_TUNE_CMD=ngps_acq # command to run after guiding for final fine tune +ACQ_FINE_TUNE_XTERM=0 # run fine tune in xterm (0/1) +ACQ_OFFSET_SETTLE=0 # seconds to wait after automatic offset # Calibration Settings # CAL_TARGET=(name caldoor calcover U G R I lampthar lampfear lampbluc lampredc lolamp hilamp mod1 mod2 ... mod6) diff --git a/common/sequencerd_commands.h b/common/sequencerd_commands.h index cf38c57d..5797ba02 100644 --- a/common/sequencerd_commands.h +++ b/common/sequencerd_commands.h @@ -8,6 +8,7 @@ #ifndef SEQEUNCERD_COMMANDS_H #define SEQEUNCERD_COMMANDS_H const std::string SEQUENCERD_ABORT = "abort"; +const std::string SEQUENCERD_ACQMODE = "acqmode"; const std::string SEQUENCERD_CONFIG = "config"; const std::string SEQUENCERD_DOTYPE = "do"; const std::string SEQUENCERD_EXIT = "exit"; @@ -47,6 +48,7 @@ const std::vector SEQUENCERD_SYNTAX = { SEQUENCERD_TCS+" ...", "", SEQUENCERD_ABORT, + SEQUENCERD_ACQMODE+" [ ? | ]", SEQUENCERD_CONFIG, SEQUENCERD_DOTYPE+" [ one | all ]", SEQUENCERD_EXIT, diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index 2e2f9410..6e3ca870 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -12,7 +12,11 @@ */ #include "sequence.h" -#include "message_keys.h" + +#include +#include +#include +#include namespace Sequencer { @@ -41,23 +45,6 @@ namespace Sequencer { /***** Sequencer::Sequence::handletopic_snapshot ***************************/ - /***** Sequencer::Sequence::handletopic_camerad ****************************/ - /** - * @brief handles camerad telemetry - * @param[in] jmessage subscribed-received JSON message - * - */ - void Sequence::handletopic_camerad(const nlohmann::json &jmessage) { - if (jmessage.contains(Key::Camerad::READY)) { - int isready = jmessage[Key::Camerad::READY].get(); - this->can_expose.store(isready, std::memory_order_relaxed); - std::lock_guard lock(camerad_mtx); - this->camerad_cv.notify_all(); - } - } - /***** Sequencer::Sequence::handletopic_camerad ****************************/ - - /***** Sequencer::Sequence::publish_snapshot *******************************/ /** * @brief publishes snapshot of my telemetry @@ -578,40 +565,188 @@ namespace Sequencer { * std::thread( &Sequencer::Sequence::dothread_acquisition, this ).detach(); ***/ - // If not a calibration target then introduce a pause for the user - // to make adjustments, send offsets, etc. + // If not a calibration target then handle acquisition automation // if ( !this->target.iscal ) { - - // waiting for user signal (or cancel) - // - // The sequencer is effectively paused waiting for user input. This - // gives the user a chance to ensure the correct target is on the slit, - // select offset stars, etc. - // { - ScopedState wait_state( wait_state_manager, Sequencer::SEQ_WAIT_USER ); + std::stringstream mode_msg; + mode_msg << "NOTICE: acquisition automation mode " << this->acq_automatic_mode; + this->async.enqueue_and_log( function, mode_msg.str() ); + } - this->async.enqueue_and_log( function, "NOTICE: waiting for USER to send \"continue\" signal" ); + auto wait_for_user = [&](const std::string ¬ice) -> bool { + this->is_usercontinue.store(false); + ScopedState wait_state( wait_state_manager, Sequencer::SEQ_WAIT_USER ); + this->async.enqueue_and_log( function, "NOTICE: "+notice ); + while ( !this->cancel_flag.load() && !this->is_usercontinue.load() ) { + std::unique_lock lock(cv_mutex); + this->cv.wait( lock, [this]() { return( this->is_usercontinue.load() || this->cancel_flag.load() ); } ); + } + this->async.enqueue_and_log( function, "NOTICE: received " + +(this->cancel_flag.load() ? std::string("cancel") : std::string("continue")) + +" signal!" ); + if ( this->cancel_flag.load() ) return false; + this->is_usercontinue.store(false); + return true; + }; + + auto wait_for_guiding = [&]() -> long { + ScopedState wait_state( wait_state_manager, Sequencer::SEQ_WAIT_GUIDE ); + this->async.enqueue_and_log( function, "NOTICE: waiting for ACAM guiding" ); + auto start_time = std::chrono::steady_clock::now(); + const bool use_timeout = ( this->acquisition_timeout > 0 ); + const auto timeout = std::chrono::duration( this->acquisition_timeout ); + while ( !this->cancel_flag.load() ) { + std::string reply; + if ( this->acamd.command( ACAMD_ACQUIRE, reply ) != NO_ERROR ) { + logwrite( function, "ERROR reading ACAM acquire state" ); + return ERROR; + } + if ( reply.find( "guiding" ) != std::string::npos ) return NO_ERROR; + if ( reply.find( "stopped" ) != std::string::npos ) return ERROR; + if ( use_timeout && std::chrono::steady_clock::now() > ( start_time + timeout ) ) return TIMEOUT; + std::this_thread::sleep_for( std::chrono::milliseconds(500) ); + } + return ERROR; + }; - while ( !this->cancel_flag.load() && !this->is_usercontinue.load() ) { - std::unique_lock lock(cv_mutex); - this->cv.wait( lock, [this]() { return( this->is_usercontinue.load() || this->cancel_flag.load() ); } ); - } + auto run_fine_tune = [&]() -> long { + if ( this->acq_fine_tune_cmd.empty() ) return NO_ERROR; + this->async.enqueue_and_log( function, "NOTICE: running fine tune command: "+this->acq_fine_tune_cmd ); - this->async.enqueue_and_log( function, "NOTICE: received " - +(this->cancel_flag.load() ? std::string("cancel") : std::string("continue")) - +" signal!" ); - } // end scope for wait_state = WAIT_USER + if ( this->acq_fine_tune_xterm ) { + this->async.enqueue_and_log( function, "NOTICE: launching fine tune in xterm" ); + } - if ( this->cancel_flag.load() ) { - this->async.enqueue_and_log( function, "NOTICE: sequence cancelled" ); - return; - } + pid_t pid = fork(); + if ( pid == 0 ) { + // make a dedicated process group so we can signal the whole tree + setpgid( 0, 0 ); + if ( this->acq_fine_tune_xterm ) { + execlp( "xterm", "xterm", "-T", "NGPS Fine Tune", "-e", + "sh", "-lc", this->acq_fine_tune_cmd.c_str(), (char*)nullptr ); + // fall through if xterm is missing + } + execl( "/bin/sh", "sh", "-c", this->acq_fine_tune_cmd.c_str(), (char*)nullptr ); + _exit(127); + } + if ( pid < 0 ) { + logwrite( function, "ERROR starting fine tune command: "+this->acq_fine_tune_cmd ); + return ERROR; + } + // Ensure the child is its own process group (best effort). + setpgid( pid, pid ); + this->fine_tune_pid.store( pid ); + + int status = 0; + while ( true ) { + pid_t result = waitpid( pid, &status, WNOHANG ); + if ( result == pid ) break; + if ( result < 0 ) { + logwrite( function, "ERROR waiting on fine tune command" ); + return ERROR; + } + if ( this->cancel_flag.load() ) { + this->async.enqueue_and_log( function, "NOTICE: abort requested; terminating fine tune" ); + // terminate the whole fine-tune process group + kill( -pid, SIGTERM ); + auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while ( std::chrono::steady_clock::now() < deadline ) { + result = waitpid( pid, &status, WNOHANG ); + if ( result == pid ) break; + std::this_thread::sleep_for( std::chrono::milliseconds(100) ); + } + if ( result != pid ) { + kill( -pid, SIGKILL ); + waitpid( pid, &status, 0 ); + } + this->fine_tune_pid.store( 0 ); + return ERROR; + } + std::this_thread::sleep_for( std::chrono::milliseconds(100) ); + } - this->is_usercontinue.store(false); + this->fine_tune_pid.store( 0 ); + if ( WIFEXITED( status ) && WEXITSTATUS( status ) == 0 ) { + this->async.enqueue_and_log( function, "NOTICE: fine tune complete" ); + return NO_ERROR; + } + + logwrite( function, "ERROR fine tune command failed: "+this->acq_fine_tune_cmd ); + return ERROR; + }; + + if ( this->acq_automatic_mode == 1 ) { + if ( !wait_for_user( "waiting for USER to send \"continue\" signal" ) ) { + this->async.enqueue_and_log( function, "NOTICE: sequence cancelled" ); + return; + } + } + else { + // Check if target coordinates match the last target (same logic as in move_to_target) + // If so, skip acquisition for repeat target + // + bool is_repeat_target = ( this->target.ra_hms == this->last_ra_hms && + this->target.dec_dms == this->last_dec_dms ); + + if ( is_repeat_target ) { + this->async.enqueue_and_log( function, "NOTICE: skipping acquisition for repeat target" ); + } + else { + if ( this->acq_automatic_mode == 2 ) { + if ( !wait_for_user( "waiting for USER to send \"continue\" signal to start acquisition" ) ) { + this->async.enqueue_and_log( function, "NOTICE: sequence cancelled" ); + return; + } + } - this->async.enqueue_and_log( function, "NOTICE: received USER continue signal!" ); + this->async.enqueue_and_log( function, "NOTICE: starting acquisition" ); + std::thread( &Sequencer::Sequence::dothread_acquisition, this ).detach(); + + long acqerr = wait_for_guiding(); + if ( acqerr != NO_ERROR ) { + std::string reason = ( acqerr == TIMEOUT ? "timeout" : "error" ); + this->async.enqueue_and_log( function, "WARNING: failed to reach guiding state ("+reason+"); falling back to manual continue" ); + if ( !wait_for_user( "waiting for USER to send \"continue\" signal to expose (guiding failed)" ) ) { + this->async.enqueue_and_log( function, "NOTICE: sequence cancelled" ); + return; + } + } + else { + bool fine_tune_ok = ( run_fine_tune() == NO_ERROR ); + if ( !fine_tune_ok ) { + this->async.enqueue_and_log( function, "WARNING: fine tune failed; waiting for USER continue to expose" ); + if ( !wait_for_user( "waiting for USER to send \"continue\" signal to expose (fine tune failed)" ) ) { + this->async.enqueue_and_log( function, "NOTICE: sequence cancelled" ); + return; + } + } + + if ( fine_tune_ok ) { + if ( this->acq_automatic_mode == 2 ) { + if ( !wait_for_user( "waiting for USER to send \"continue\" signal to expose" ) ) { + this->async.enqueue_and_log( function, "NOTICE: sequence cancelled" ); + return; + } + } + else if ( this->acq_automatic_mode == 3 ) { + if ( this->target.offset_ra != 0.0 || this->target.offset_dec != 0.0 ) { + this->async.enqueue_and_log( function, "NOTICE: applying target offset automatically" ); + error |= this->target_offset(); + if ( error != NO_ERROR ) { + this->thread_error_manager.set( THR_ACQUISITION ); + return; + } + if ( this->acq_offset_settle > 0 ) { + this->async.enqueue_and_log( function, "NOTICE: waiting for offset settle time" ); + std::this_thread::sleep_for( std::chrono::duration( this->acq_offset_settle ) ); + } + } + } + } + } + } + } // Ensure slit offset is in "expose" position // @@ -741,23 +876,6 @@ namespace Sequencer { std::stringstream camcmd; long error=NO_ERROR; - // wait until camera is ready to expose - // - std::unique_lock lock(this->camerad_mtx); - if (!this->can_expose.load()) { - - this->async.enqueue_and_log(function, "NOTICE: waiting for camera to be ready to expose"); - - this->camerad_cv.wait( lock, [this]() { - return( this->can_expose.load() || this->cancel_flag.load() ); - } ); - - if (this->cancel_flag.load()) { - logwrite(function, "sequence cancelled"); - return NO_ERROR; - } - } - logwrite( function, "setting camera parameters"); ScopedState thr_state( thread_state_manager, Sequencer::THR_CAMERA_SET ); @@ -765,35 +883,6 @@ namespace Sequencer { this->thread_error_manager.set( THR_CAMERA_SET ); // assume the worse, clear on success - // Controller activate states stored in Sequencer::CalibrationTarget::calinfo map, - // indexed by name. Calibration targets use target.name for the index, or - // use "SCIENCE" index for all science targets. - // - std::ostringstream activechans, deactivechans; - const std::string calname = std::string(this->target.iscal ? this->target.name : "SCIENCE"); - const auto &calinfo = this->caltarget.get_info(calname); - - // build up lists of (de)activate chans - for (const auto &[chan,active] : calinfo.channel_active) { - (active ? activechans : deactivechans) << " " << chan; - } - - // send two commands, one for each - if (!activechans.str().empty()) { - std::string cmd = CAMERAD_ACTIVATE + activechans.str(); - if (this->camerad.send(cmd, reply)!=NO_ERROR) { - this->async.enqueue_and_log(function, "ERROR sending \""+cmd+"\": "+reply); - throw std::runtime_error("camera returned "+reply); - } - } - if (!deactivechans.str().empty()) { - std::string cmd = CAMERAD_DEACTIVATE + deactivechans.str(); - if (this->camerad.send(cmd, reply)!=NO_ERROR) { - this->async.enqueue_and_log(function, "ERROR sending \""+cmd+"\": "+reply); - throw std::runtime_error("camera returned "+reply); - } - } - // send the EXPTIME command to camerad // // Everywhere is maintained that exptime is specified in sec except @@ -2175,21 +2264,34 @@ namespace Sequencer { this->thread_error_manager.set( THR_CALIBRATOR_SET ); // assume the worse, clear on success - const std::string calname = std::string(this->target.iscal ? this->target.name : "SCIENCE"); + // name will index the caltarget map + // + std::string name(this->target.name); + + if ( this->target.iscal ) { + name = this->target.name; + this->async.enqueue_and_log( function, "NOTICE: configuring calibrator for "+name ); + } + else { + this->async.enqueue_and_log( function, "NOTICE: disabling calibrator for science target "+name ); + name="SCIENCE"; // override for indexing the map + } // Get the calibration target map. // This contains a map of all the required settings, indexed by target name. // - const auto &calinfo = this->caltarget.get_info(calname); - - this->async.enqueue_and_log(function, "NOTICE: configuring calibrator for "+calname); + auto calinfo = this->caltarget.get_info(name); + if (!calinfo) { + logwrite( function, "ERROR unrecognized calibration target: "+name ); + throw std::runtime_error("unrecognized calibration target: "+name); + } // set the calib door and cover // std::stringstream cmd; cmd.str(""); cmd << CALIBD_SET - << " door=" << ( calinfo.caldoor ? "open" : "close" ) - << " cover=" << ( calinfo.calcover ? "open" : "close" ); + << " door=" << ( calinfo->caldoor ? "open" : "close" ) + << " cover=" << ( calinfo->calcover ? "open" : "close" ); logwrite( function, "calib: "+cmd.str() ); if ( !this->cancel_flag.load() && @@ -2200,7 +2302,7 @@ namespace Sequencer { // set the internal calibration lamps // - for ( const auto &[lamp,state] : calinfo.lamp ) { + for ( const auto &[lamp,state] : calinfo->lamp ) { if ( this->cancel_flag.load() ) break; cmd.str(""); cmd << lamp << " " << (state?"on":"off"); message.str(""); message << "power " << cmd.str(); @@ -2216,7 +2318,7 @@ namespace Sequencer { // // // set the dome lamps // // -// for ( const auto &[lamp,state] : calinfo.domelamp ) { +// for ( const auto &[lamp,state] : calinfo->domelamp ) { // if ( this->cancel_flag.load() ) break; // cmd.str(""); cmd << TCSD_NATIVE << " NPS " << lamp << " " << (state?1:0); // if ( this->tcsd.command( cmd.str() ) != NO_ERROR ) { @@ -2227,7 +2329,7 @@ namespace Sequencer { // set the lamp modulators // - for ( const auto &[mod,state] : calinfo.lampmod ) { + for ( const auto &[mod,state] : calinfo->lampmod ) { if ( this->cancel_flag.load() ) break; cmd.str(""); cmd << CALIBD_LAMPMOD << " " << mod << " " << (state?1:0) << " 1000"; if ( this->calibd.command( cmd.str() ) != NO_ERROR ) { @@ -2273,6 +2375,26 @@ namespace Sequencer { this->cancel_flag.store(true); this->cv.notify_all(); + // terminate fine tune process if running + // + pid_t ftpid = this->fine_tune_pid.load(); + if ( ftpid > 0 ) { + logwrite( function, "NOTICE: terminating fine tune process" ); + kill( -ftpid, SIGTERM ); + auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + int status = 0; + while ( std::chrono::steady_clock::now() < deadline ) { + pid_t result = waitpid( ftpid, &status, WNOHANG ); + if ( result == ftpid ) break; + std::this_thread::sleep_for( std::chrono::milliseconds(100) ); + } + if ( waitpid( ftpid, &status, WNOHANG ) == 0 ) { + kill( -ftpid, SIGKILL ); + waitpid( ftpid, &status, 0 ); + } + this->fine_tune_pid.store( 0 ); + } + // drop into do-one to prevent auto increment to next target // this->do_once.store(true); @@ -4027,10 +4149,6 @@ namespace Sequencer { message.str(""); message << "NOTICE: daemons not ready: " << this->daemon_manager.get_cleared_states(); this->async.enqueue_and_log( function, message.str() ); - retstring.append( message.str() ); retstring.append( "\n" ); - - message.str(""); message << "NOTICE: camera ready to expose: " << (this->can_expose.load() ? "yes" : "no"); - this->async.enqueue_and_log( function, message.str() ); retstring.append( message.str() ); error = NO_ERROR; diff --git a/sequencerd/sequence.h b/sequencerd/sequence.h index c931cce2..1ff77753 100644 --- a/sequencerd/sequence.h +++ b/sequencerd/sequence.h @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -33,7 +34,6 @@ #include "slitd_commands.h" #include "tcsd_commands.h" #include "sequencerd_commands.h" -#include "message_keys.h" #include "tcs_constants.h" #include "acam_interface_shared.h" @@ -154,6 +154,7 @@ namespace Sequencer { SEQ_WAIT_TCS, ///< set when waiting for tcs // states SEQ_WAIT_ACQUIRE, ///< set when waiting for acquire + SEQ_WAIT_GUIDE, ///< set when waiting for guiding state SEQ_WAIT_EXPOSE, ///< set when waiting for camera exposure SEQ_WAIT_READOUT, ///< set when waiting for camera readout SEQ_WAIT_TCSOP, ///< set when waiting specifically for tcs operator @@ -174,6 +175,7 @@ namespace Sequencer { {SEQ_WAIT_TCS, "TCS"}, // states {SEQ_WAIT_ACQUIRE, "ACQUIRE"}, + {SEQ_WAIT_GUIDE, "GUIDE"}, {SEQ_WAIT_EXPOSE, "EXPOSE"}, {SEQ_WAIT_READOUT, "READOUT"}, {SEQ_WAIT_TCSOP, "TCSOP"}, @@ -281,13 +283,13 @@ namespace Sequencer { private: zmqpp::context context; bool ready_to_start; ///< set on nightly startup success, used to return seqstate to READY after an abort - std::atomic can_expose; std::atomic is_science_frame_transfer; ///< is frame transfer enabled for science cameras std::atomic notify_tcs_next_target; ///< notify TCS of next target when remaining time within TCS_PREAUTH_TIME std::atomic arm_readout_flag; ///< std::atomic cancel_flag{false}; std::atomic is_ontarget{false}; ///< remotely set by the TCS operator to indicate that the target is ready std::atomic is_usercontinue{false}; ///< remotely set by the user to continue + std::atomic fine_tune_pid{0}; ///< fine tune process pid (process group leader) /** @brief safely runs function in a detached thread using lambda to catch exceptions */ @@ -309,12 +311,15 @@ namespace Sequencer { Sequence() : context(), ready_to_start(false), - can_expose(false), is_science_frame_transfer(false), notify_tcs_next_target(false), arm_readout_flag(false), acquisition_timeout(0), acquisition_max_retrys(-1), + acq_automatic_mode(1), + acq_fine_tune_cmd("ngps_acq"), + acq_fine_tune_xterm(false), + acq_offset_settle(0), tcs_offsetrate_ra(45), tcs_offsetrate_dec(45), tcs_settle_timeout(10), @@ -337,10 +342,8 @@ namespace Sequencer { daemon_manager.set_callback([this](const std::bitset& states) { broadcast_daemonstate(); }); topic_handlers = { - { Topic::SNAPSHOT, std::function( - [this](const nlohmann::json &msg) { handletopic_snapshot(msg); } ) }, - { Topic::CAMERAD, std::function( - [this](const nlohmann::json &msg) { handletopic_camerad(msg); } ) } + { "_snapshot", std::function( + [this](const nlohmann::json &msg) { handletopic_snapshot(msg); } ) } }; } @@ -374,6 +377,10 @@ namespace Sequencer { double acquisition_timeout; ///< timeout for target acquisition (in sec) set by configuration parameter ACAM_ACQUIRE_TIMEOUT int acquisition_max_retrys; ///< max number of acquisition loop attempts + int acq_automatic_mode; ///< acquisition automation mode (1=legacy, 2=semi-auto, 3=auto) + std::string acq_fine_tune_cmd; ///< fine-tune command to run after guiding + bool acq_fine_tune_xterm; ///< run fine-tune command in its own xterm + double acq_offset_settle; ///< seconds to wait after automatic offset double tcs_offsetrate_ra; ///< TCS offset rate RA ("MRATE") in arcsec per second double tcs_offsetrate_dec; ///< TCS offset rate DEC ("MRATE") in arcsec per second double tcs_settle_timeout; ///< timeout for telescope to settle (in sec) set by configuration parameter TCS_SETTLE_TIMEOUT @@ -384,8 +391,6 @@ namespace Sequencer { /// std::mutex tcs_ontarget_mtx; /// std::condition_variable tcs_ontarget_cv; - std::mutex camerad_mtx; - std::condition_variable camerad_cv; std::mutex wait_mtx; std::condition_variable cv; std::mutex cv_mutex; @@ -455,7 +460,6 @@ namespace Sequencer { void stop_subscriber_thread() { Common::PubSubHandler::stop_subscriber_thread(*this); } void handletopic_snapshot( const nlohmann::json &jmessage ); - void handletopic_camerad( const nlohmann::json &jmessage ); void publish_snapshot(); void publish_snapshot(std::string &retstring); void publish_seqstate(); diff --git a/sequencerd/sequencer_server.cpp b/sequencerd/sequencer_server.cpp index 06514cef..4407225d 100644 --- a/sequencerd/sequencer_server.cpp +++ b/sequencerd/sequencer_server.cpp @@ -392,6 +392,74 @@ namespace Sequencer { applied++; } + // ACQ_AUTOMATIC_MODE + if (config.param[entry] == "ACQ_AUTOMATIC_MODE") { + int mode=1; + try { + mode = std::stoi( config.arg[entry] ); + if ( mode < 1 || mode > 3 ) { + message.str(""); message << "ERROR: ACQ_AUTOMATIC_MODE " << mode << " out of range {1:3}"; + this->sequence.async.enqueue_and_log( function, message.str() ); + return ERROR; + } + } + catch (const std::exception &e) { + message.str(""); message << "ERROR parsing ACQ_AUTOMATIC_MODE: " << e.what(); + this->sequence.async.enqueue_and_log( function, message.str() ); + return ERROR; + } + this->sequence.acq_automatic_mode = mode; + message.str(""); message << "SEQUENCERD:config:" << config.param[entry] << "=" << config.arg[entry]; + this->sequence.async.enqueue_and_log( function, message.str() ); + applied++; + } + + // ACQ_FINE_TUNE_CMD + if (config.param[entry] == "ACQ_FINE_TUNE_CMD") { + this->sequence.acq_fine_tune_cmd = config.arg[entry]; + message.str(""); message << "SEQUENCERD:config:" << config.param[entry] << "=" << config.arg[entry]; + this->sequence.async.enqueue_and_log( function, message.str() ); + applied++; + } + + // ACQ_FINE_TUNE_XTERM + if (config.param[entry] == "ACQ_FINE_TUNE_XTERM") { + try { + int val = std::stoi( config.arg[entry] ); + this->sequence.acq_fine_tune_xterm = ( val != 0 ); + } + catch (const std::exception &e) { + message.str(""); message << "ERROR parsing ACQ_FINE_TUNE_XTERM: " << e.what(); + this->sequence.async.enqueue_and_log( function, message.str() ); + return ERROR; + } + message.str(""); message << "SEQUENCERD:config:" << config.param[entry] << "=" << config.arg[entry]; + this->sequence.async.enqueue_and_log( function, message.str() ); + applied++; + } + + // ACQ_OFFSET_SETTLE + if (config.param[entry] == "ACQ_OFFSET_SETTLE") { + double settle=0; + try { + settle = std::stod( config.arg[entry] ); + if ( settle < 0 ) { + message.str(""); message << "ERROR: ACQ_OFFSET_SETTLE " << settle << " out of range (>=0)"; + this->sequence.async.enqueue_and_log( function, message.str() ); + return ERROR; + } + } + catch (const std::exception &e) { + message.str(""); message << "ERROR parsing ACQ_OFFSET_SETTLE: " << e.what(); + this->sequence.async.enqueue_and_log( function, message.str() ); + return ERROR; + } + this->sequence.acq_offset_settle = settle; + message.str(""); message << "SEQUENCERD:config:" << config.param[entry] << "=" << config.arg[entry]; + this->sequence.async.enqueue_and_log( function, message.str() ); + applied++; + } + // TCS_WHICH -- which TCS to connect to, defults to real if not specified if ( config.param[entry] == "TCS_WHICH" ) { if ( config.arg[entry] != "sim" && config.arg[entry] != "real" ) { @@ -1443,6 +1511,41 @@ namespace Sequencer { } else + // Set/Get acquisition automation mode + // + if ( cmd == SEQUENCERD_ACQMODE ) { + if ( args.empty() || args == "?" || args == "help" ) { + retstring = SEQUENCERD_ACQMODE + " [ ]\n"; + retstring.append( " Set or get acquisition automation mode.\n" ); + retstring.append( " = { 1 (legacy), 2 (semi-auto), 3 (auto) }\n" ); + if ( args.empty() ) { + retstring.append( " current mode = " + std::to_string(this->sequence.acq_automatic_mode) + "\n" ); + } + ret = HELP; + } + else { + try { + int mode = std::stoi( args ); + if ( mode < 1 || mode > 3 ) { + this->sequence.async.enqueue_and_log( function, "ERROR: acqmode out of range {1:3}" ); + ret = ERROR; + } + else { + this->sequence.acq_automatic_mode = mode; + message.str(""); message << "NOTICE: acqmode set to " << mode; + this->sequence.async.enqueue_and_log( function, message.str() ); + retstring = std::to_string( mode ); + ret = NO_ERROR; + } + } + catch ( const std::exception & ) { + this->sequence.async.enqueue_and_log( function, "ERROR: invalid acqmode argument" ); + ret = ERROR; + } + } + } + else + // // if ( cmd == SEQUENCERD_GETONETARGET ) { From ea2c80f59e7fe6f2ffbde595d1c673c7cc4804eb Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sat, 7 Feb 2026 16:45:30 -0800 Subject: [PATCH 21/74] Add C++ Qt database GUI for target management - Qt Widgets GUI for managing target sets and targets - Direct database access via MySQL Connector/C++ (X DevAPI) - Features: table views, inline editing, drag/drop reordering - Search functionality and sequencer controls - Build with CMake, reads DB settings from Config/sequencerd.cfg --- tools/ngps_db_gui/CMakeLists.txt | 41 + tools/ngps_db_gui/README.md | 34 + tools/ngps_db_gui/main.cpp | 7972 ++++++++++++++++++++++++++++++ 3 files changed, 8047 insertions(+) create mode 100644 tools/ngps_db_gui/CMakeLists.txt create mode 100644 tools/ngps_db_gui/README.md create mode 100644 tools/ngps_db_gui/main.cpp diff --git a/tools/ngps_db_gui/CMakeLists.txt b/tools/ngps_db_gui/CMakeLists.txt new file mode 100644 index 00000000..97a31d82 --- /dev/null +++ b/tools/ngps_db_gui/CMakeLists.txt @@ -0,0 +1,41 @@ +cmake_minimum_required(VERSION 3.16) +project(ngps_db_gui LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) +set(CMAKE_AUTOUIC ON) + +find_package(Qt6 COMPONENTS Widgets QUIET) +if (Qt6_FOUND) + set(QT_LIBS Qt6::Widgets) +else() + find_package(Qt5 COMPONENTS Widgets REQUIRED) + set(QT_LIBS Qt5::Widgets) +endif() + +set(MYSQL_DIR "/usr/local/mysql/connector") +find_path(MYSQL_API "mysqlx/xdevapi.h" + PATHS ${MYSQL_DIR} /usr/local/opt/mysql-connector-c++ /opt/homebrew/opt/mysql-connector-c++) +if (NOT MYSQL_API) + message(FATAL_ERROR "mysqlx/xdevapi.h not found. Install MySQL Connector/C++") +endif() + +find_library(MYSQL_LIB NAMES mysqlcppconnx mysqlcppconn8 mysqlcppconn + PATHS ${MYSQL_DIR} + /usr/local/opt/mysql-connector-c++/lib + /opt/homebrew/opt/mysql-connector-c++/lib + /usr/local/opt/mysql-connector-c++ + /opt/homebrew/opt/mysql-connector-c++) +if (NOT MYSQL_LIB) + message(FATAL_ERROR "MySQL Connector/C++ library not found") +endif() + +add_executable(ngps_db_gui + main.cpp +) + +target_include_directories(ngps_db_gui PRIVATE ${MYSQL_API}) +target_link_libraries(ngps_db_gui PRIVATE ${QT_LIBS} ${MYSQL_LIB}) diff --git a/tools/ngps_db_gui/README.md b/tools/ngps_db_gui/README.md new file mode 100644 index 00000000..01069b96 --- /dev/null +++ b/tools/ngps_db_gui/README.md @@ -0,0 +1,34 @@ +# NGPS DB GUI (Qt/C++) + +This tool provides a Qt Widgets GUI for managing NGPS target sets and targets. + +## Features +- Table views populated directly from the database +- Add dialog for new rows; inline edit by double-click +- Manual refresh button to reload from the DB +- Search targets by `NAME` (case-insensitive, partial match) +- Active targets can be reordered (drag/drop or right-click) +- Reordering updates `OBS_ORDER` using `OBSERVATION_ID` to avoid duplicates +- Set View tab tracks the currently selected target set +- Sequencer controls: `seq start` and `seq abort` +- Activate a target set via `seq targetset ` + +## Build +From this directory: + +```bash +cmake -S . -B build +cmake --build build +``` + +## Run +```bash +./build/ngps_db_gui +``` + +## Notes +- The GUI loads DB settings from `Config/sequencerd.cfg` (auto-detected). +- Requires MySQL Connector/C++ (X DevAPI) for direct DB access (e.g. `mysql-connector-c++`). +- The sequencer buttons run the `seq` command. Set `SEQUENCERD_CONFIG` or + `NGPS_ROOT` in your environment if needed. +- To set a nullable field to NULL in the table, clear the cell or type `NULL`. diff --git a/tools/ngps_db_gui/main.cpp b/tools/ngps_db_gui/main.cpp new file mode 100644 index 00000000..89a3578b --- /dev/null +++ b/tools/ngps_db_gui/main.cpp @@ -0,0 +1,7972 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +const char kSettingsOrg[] = "NGPS"; +const char kSettingsApp[] = "ngps_db_gui"; + +const double kDefaultWrangeHalfWidthNm = 15.0; // +/- 150 Ã… +const char kDefaultExptime[] = "SET 5"; +const char kDefaultSlitwidth[] = "SET 1"; +const char kDefaultSlitangle[] = "PA"; +const char kDefaultMagsystem[] = "AB"; +const char kDefaultMagfilter[] = "match"; +const char kDefaultChannel[] = "R"; +const char kDefaultPointmode[] = "SLIT"; +const char kDefaultCcdmode[] = "default"; +const char kDefaultNotBefore[] = "1999-12-31T12:34:56"; +const double kDefaultMagnitude = 19.0; +const double kDefaultAirmassMax = 4.0; +const int kDefaultBin = 1; +const double kDefaultOtmSlitwidth = 1.0; +const char kDefaultTargetState[] = "pending"; +const double kGroupCoordTolArcsec = 1.0; +const double kOffsetZeroTolArcsec = 0.1; +} + +struct NormalizationResult { + QStringList changedColumns; + QString message; +}; + +struct TimelineTarget { + QString obsId; + QString name; + QDateTime startUtc; + QDateTime endUtc; + QVector> segments; + QDateTime slewGoUtc; + QVector airmass; + int obsOrder = 0; + QString flag; + int severity = 0; // 0=none,1=warn,2=error + bool observed = false; + double waitSec = 0.0; +}; + +struct TimelineData { + QVector timesUtc; + QDateTime twilightEvening16; + QDateTime twilightEvening12; + QDateTime twilightMorning12; + QDateTime twilightMorning16; + QVector targets; + double airmassLimit = 0.0; + double delaySlewSec = 0.0; + QVector> idleIntervals; +}; + +static QString explainOtmFlagToken(const QString &token) { + const QString t = token.trimmed().toUpper(); + if (t.isEmpty()) return QString(); + if (t == "DAY-0") { + return "DAY-0: Start time was before twilight; scheduler waited until night."; + } + if (t == "DAY-1") { + return "DAY-1: Daylight reached during/after exposure; remaining targets skipped."; + } + if (t == "DAY-0-1") { + return "DAY-0-1: Daylight reached before this target; target and remaining skipped."; + } + if (t == "SKY") { + return "SKY: Sky background model unavailable/invalid; used fallback sky magnitude."; + } + if (t == "EXPT") { + return "EXPT: Exposure time exceeded warning threshold."; + } + QRegularExpression dofRe("^(AIR|ALT|HA)-([01])$"); + const QRegularExpressionMatch m = dofRe.match(t); + if (m.hasMatch()) { + const QString dof = m.captured(1); + const QString phase = m.captured(2); + const QString phaseText = (phase == "0") ? "before exposure" : "after exposure"; + if (dof == "AIR") { + return QString("AIR-%1: Airmass limit violated %2.").arg(phase, phaseText); + } + if (dof == "ALT") { + return QString("ALT-%1: Altitude limit violated %2.").arg(phase, phaseText); + } + if (dof == "HA") { + return QString("HA-%1: Hour angle limit violated %2.").arg(phase, phaseText); + } + } + return QString("Unknown flag: %1").arg(token); +} + +static QString explainOtmFlags(const QString &flagText) { + QStringList tokens = flagText.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); + if (tokens.isEmpty()) return QString(); + QStringList lines; + for (const QString &token : tokens) { + const QString explanation = explainOtmFlagToken(token); + if (!explanation.isEmpty()) lines << "- " + explanation; + } + if (lines.isEmpty()) return QString(); + return lines.join('\n'); +} + +static QString formatNumber(double value, int precision = 8) { + return QLocale::c().toString(value, 'g', precision); +} + +static QString variantToString(const QVariant &value) { + if (!value.isValid() || value.isNull()) return QString(); + const int type = value.userType(); + if (type == QMetaType::Double || type == QMetaType::Float) { + return formatNumber(value.toDouble()); + } + if (type == QMetaType::Int || type == QMetaType::LongLong) { + return QString::number(value.toLongLong()); + } + if (type == QMetaType::UInt || type == QMetaType::ULongLong) { + return QString::number(value.toULongLong()); + } + return value.toString().trimmed(); +} + +static QString trimOrEmpty(const QString &text) { + return text.trimmed(); +} + +static QStringList splitTokens(const QString &text) { + return text.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); +} + +static QString findKeyCaseInsensitive(const QVariantMap &values, const QString &key) { + if (values.contains(key)) return key; + for (auto it = values.begin(); it != values.end(); ++it) { + if (it.key().compare(key, Qt::CaseInsensitive) == 0) { + return it.key(); + } + } + return key; +} + +static QString valueForKeyCaseInsensitive(const QVariantMap &values, const QString &key) { + const QString actual = findKeyCaseInsensitive(values, key); + return values.value(actual).toString(); +} + +static QString valueToStringCaseInsensitive(const QVariantMap &values, const QString &key) { + const QString actual = findKeyCaseInsensitive(values, key); + return variantToString(values.value(actual)); +} + +static bool containsKeyCaseInsensitive(const QVariantMap &values, const QString &key) { + if (values.contains(key)) return true; + for (auto it = values.begin(); it != values.end(); ++it) { + if (it.key().compare(key, Qt::CaseInsensitive) == 0) { + return true; + } + } + return false; +} + +static bool setContainsCaseInsensitive(const QSet &set, const QString &key) { + if (set.contains(key)) return true; + for (const QString &entry : set) { + if (entry.compare(key, Qt::CaseInsensitive) == 0) return true; + } + return false; +} + +static void setRemoveCaseInsensitive(QSet &set, const QString &key) { + if (set.contains(key)) { + set.remove(key); + return; + } + QString removeKey; + for (const QString &entry : set) { + if (entry.compare(key, Qt::CaseInsensitive) == 0) { + removeKey = entry; + break; + } + } + if (!removeKey.isEmpty()) set.remove(removeKey); +} + +static bool parseDouble(const QString &text, double *value) { + bool ok = false; + const double v = QLocale::c().toDouble(text.trimmed(), &ok); + if (ok && value) *value = v; + return ok; +} + +static bool parseInt(const QString &text, int *value) { + bool ok = false; + const int v = text.trimmed().toInt(&ok); + if (ok && value) *value = v; + return ok; +} + +static bool parseSexagesimalAngle(const QString &text, bool isRa, double *degOut) { + const QString trimmed = text.trimmed(); + if (!trimmed.contains(':')) return false; + const QStringList parts = trimmed.split(':'); + if (parts.size() < 2) return false; + bool ok0 = false; + const QString first = parts.at(0).trimmed(); + const bool neg = (!isRa && first.startsWith('-')); + double a = first.toDouble(&ok0); + if (!ok0) return false; + a = std::abs(a); + bool ok1 = false; + double b = parts.at(1).trimmed().toDouble(&ok1); + if (!ok1) return false; + b = std::abs(b); + double c = 0.0; + if (parts.size() > 2) { + bool ok2 = false; + c = parts.at(2).trimmed().toDouble(&ok2); + if (!ok2) c = 0.0; + c = std::abs(c); + } + double value = a + b / 60.0 + c / 3600.0; + if (isRa) { + if (degOut) *degOut = value * 15.0; + } else { + if (degOut) *degOut = neg ? -value : value; + } + return true; +} + +static bool parseAngleDegrees(const QString &text, bool isRa, double *degOut) { + if (parseSexagesimalAngle(text, isRa, degOut)) return true; + double value = 0.0; + if (!parseDouble(text, &value)) return false; + if (isRa) { + if (std::abs(value) <= 24.0) { + value *= 15.0; + } + } + if (degOut) *degOut = value; + return true; +} + +static double offsetArcsecFromValues(const QVariantMap &values, const QStringList &keys, bool *found) { + for (const QString &key : keys) { + const QString actual = findKeyCaseInsensitive(values, key); + if (!values.contains(actual)) continue; + const QString text = values.value(actual).toString().trimmed(); + if (text.isEmpty()) continue; + double val = 0.0; + if (parseDouble(text, &val)) { + if (found) *found = true; + return val; + } + } + if (found) *found = false; + return 0.0; +} + +static bool computeScienceCoordDegreesProjected(const QVariantMap &values, + double *raDegOut, + double *decDegOut); + +static bool computeScienceCoordKey(const QVariantMap &values, QString *keyOut) { + double raDeg = 0.0; + double decDeg = 0.0; + if (!computeScienceCoordDegreesProjected(values, &raDeg, &decDeg)) return false; + + const double tolDeg = kGroupCoordTolArcsec / 3600.0; + if (tolDeg > 0.0) { + raDeg = std::round(raDeg / tolDeg) * tolDeg; + decDeg = std::round(decDeg / tolDeg) * tolDeg; + } + + while (raDeg < 0.0) raDeg += 360.0; + while (raDeg >= 360.0) raDeg -= 360.0; + + if (keyOut) { + const QString key = QString("%1:%2") + .arg(QString::number(raDeg, 'f', 6)) + .arg(QString::number(decDeg, 'f', 6)); + *keyOut = key; + } + return true; +} + +static bool computeScienceCoordDegrees(const QVariantMap &values, double *raDegOut, double *decDegOut) { + const QString raText = valueToStringCaseInsensitive(values, "RA"); + const QString decText = valueToStringCaseInsensitive(values, "DECL"); + if (raText.isEmpty() || decText.isEmpty()) return false; + double raDeg = 0.0; + double decDeg = 0.0; + if (!parseAngleDegrees(raText, true, &raDeg)) return false; + if (!parseAngleDegrees(decText, false, &decDeg)) return false; + + bool hasRa = false; + bool hasDec = false; + const double offsetRa = offsetArcsecFromValues(values, {"OFFSET_RA", "DRA"}, &hasRa); + const double offsetDec = offsetArcsecFromValues(values, {"OFFSET_DEC", "DDEC"}, &hasDec); + raDeg += offsetRa / 3600.0; + decDeg += offsetDec / 3600.0; + + while (raDeg < 0.0) raDeg += 360.0; + while (raDeg >= 360.0) raDeg -= 360.0; + + if (raDegOut) *raDegOut = raDeg; + if (decDegOut) *decDegOut = decDeg; + return true; +} + +// Gnomonic (tangent plane) projection using offsets in arcsec along RA/Dec axes. +static bool computeScienceCoordDegreesProjected(const QVariantMap &values, + double *raDegOut, + double *decDegOut) { + const QString raText = valueToStringCaseInsensitive(values, "RA"); + const QString decText = valueToStringCaseInsensitive(values, "DECL"); + if (raText.isEmpty() || decText.isEmpty()) return false; + double raDeg = 0.0; + double decDeg = 0.0; + if (!parseAngleDegrees(raText, true, &raDeg)) return false; + if (!parseAngleDegrees(decText, false, &decDeg)) return false; + + bool hasRa = false; + bool hasDec = false; + const double offsetRa = offsetArcsecFromValues(values, {"OFFSET_RA", "DRA"}, &hasRa); + const double offsetDec = offsetArcsecFromValues(values, {"OFFSET_DEC", "DDEC"}, &hasDec); + + const double deg2rad = 3.14159265358979323846 / 180.0; + const double ra0 = raDeg * deg2rad; + const double dec0 = decDeg * deg2rad; + const double xi = (offsetRa / 3600.0) * deg2rad; + const double eta = (offsetDec / 3600.0) * deg2rad; + + const double sinDec0 = std::sin(dec0); + const double cosDec0 = std::cos(dec0); + const double denom = cosDec0 - eta * sinDec0; + const double ra1 = ra0 + std::atan2(xi, denom); + const double dec1 = std::atan2(sinDec0 + eta * cosDec0, std::sqrt(denom * denom + xi * xi)); + + double raDegOutLocal = ra1 / deg2rad; + double decDegOutLocal = dec1 / deg2rad; + while (raDegOutLocal < 0.0) raDegOutLocal += 360.0; + while (raDegOutLocal >= 360.0) raDegOutLocal -= 360.0; + + if (raDegOut) *raDegOut = raDegOutLocal; + if (decDegOut) *decDegOut = decDegOutLocal; + return true; +} + +static double angularSeparationArcsec(double raDeg1, double decDeg1, + double raDeg2, double decDeg2) { + const double deg2rad = 3.14159265358979323846 / 180.0; + const double ra1 = raDeg1 * deg2rad; + const double dec1 = decDeg1 * deg2rad; + const double ra2 = raDeg2 * deg2rad; + const double dec2 = decDeg2 * deg2rad; + const double cosd = std::sin(dec1) * std::sin(dec2) + + std::cos(dec1) * std::cos(dec2) * std::cos(ra1 - ra2); + const double clamped = std::max(-1.0, std::min(1.0, cosd)); + const double sepRad = std::acos(clamped); + return sepRad * (180.0 / 3.14159265358979323846) * 3600.0; +} + +static bool channelRangeFor(const QString &channel, double *minNm, double *maxNm) { + const QString ch = channel.trimmed().toUpper(); + if (ch == "U") { if (minNm) *minNm = 310.0; if (maxNm) *maxNm = 436.0; return true; } + if (ch == "G") { if (minNm) *minNm = 417.0; if (maxNm) *maxNm = 590.0; return true; } + if (ch == "R") { if (minNm) *minNm = 561.0; if (maxNm) *maxNm = 794.0; return true; } + if (ch == "I") { if (minNm) *minNm = 756.0; if (maxNm) *maxNm = 1040.0; return true; } + return false; +} + +static QPair defaultWrangeForChannel(const QString &channel) { + double minNm = 0.0; + double maxNm = 0.0; + if (!channelRangeFor(channel, &minNm, &maxNm)) { + channelRangeFor(kDefaultChannel, &minNm, &maxNm); + } + const double center = 0.5 * (minNm + maxNm); + return {center - kDefaultWrangeHalfWidthNm, center + kDefaultWrangeHalfWidthNm}; +} + +static QString normalizeChannelValue(const QString &text) { + const QString ch = text.trimmed().toUpper(); + if (ch == "U" || ch == "G" || ch == "R" || ch == "I") return ch; + return kDefaultChannel; +} + +static QString normalizeMagsystemValue(const QString &text) { + const QString val = text.trimmed().toUpper(); + if (val == "AB" || val == "VEGA") return val; + return kDefaultMagsystem; +} + +static QString normalizeMagfilterValue(const QString &text) { + const QString val = text.trimmed(); + if (val.isEmpty()) return kDefaultMagfilter; + const QString upper = val.toUpper(); + if (upper == "G") return kDefaultMagfilter; + if (upper == "MATCH") return kDefaultMagfilter; + if (upper == "USER") return "user"; + if (upper == "U" || upper == "B" || upper == "V" || upper == "R" || + upper == "I" || upper == "J" || upper == "K") { + return upper; + } + return kDefaultMagfilter; +} + +static QString normalizePointmodeValue(const QString &text) { + const QString val = text.trimmed().toUpper(); + if (val == "SLIT" || val == "ACAM") return val; + return kDefaultPointmode; +} + +static QString normalizeCcdmodeValue(const QString &text) { + const QString val = text.trimmed(); + if (val.compare("default", Qt::CaseInsensitive) == 0) return kDefaultCcdmode; + return kDefaultCcdmode; +} + +static QString normalizeSrcmodelValue(const QString &text) { + QString val = text.trimmed(); + if (val.isEmpty()) return "-model constant"; + if (val.startsWith("-", Qt::CaseInsensitive)) return val; + if (val.startsWith("model", Qt::CaseInsensitive)) return "-" + val; + return QString("-model %1").arg(val); +} + +static QString normalizeSlitangleValue(const QString &text) { + const QString val = text.trimmed(); + if (val.isEmpty()) return kDefaultSlitangle; + if (val.compare("PA", Qt::CaseInsensitive) == 0) return "PA"; + double num = 0.0; + if (parseDouble(val, &num)) return formatNumber(num); + return kDefaultSlitangle; +} + +static QString normalizeExptimeValue(const QString &text, bool isCalib) { + Q_UNUSED(isCalib); + QString val = text.trimmed(); + if (val.isEmpty()) return kDefaultExptime; + QStringList parts = splitTokens(val); + if (parts.size() == 1) { + double num = 0.0; + if (parseDouble(parts[0], &num) && num > 0) { + return QString("SET %1").arg(formatNumber(num)); + } + return kDefaultExptime; + } + QString key = parts[0].toUpper(); + if (key == "EXPTIME") key = "SET"; + if (key == "SET" || key == "SNR") { + double num = 0.0; + if (parts.size() >= 2 && parseDouble(parts[1], &num) && num > 0) { + return QString("%1 %2").arg(key, formatNumber(num)); + } + } + return kDefaultExptime; +} + +static QString normalizeSlitwidthValue(const QString &text, bool isCalib) { + Q_UNUSED(isCalib); + QString val = text.trimmed(); + if (val.isEmpty()) return kDefaultSlitwidth; + QStringList parts = splitTokens(val); + if (parts.size() == 1) { + const QString key = parts[0].toUpper(); + if (key == "AUTO") return "AUTO"; + double num = 0.0; + if (parseDouble(parts[0], &num) && num > 0) { + return QString("SET %1").arg(formatNumber(num)); + } + return kDefaultSlitwidth; + } + QString key = parts[0].toUpper(); + if (key == "AUTO") return "AUTO"; + if (key == "SET" || key == "SNR" || key == "RES" || key == "LOSS") { + double num = 0.0; + if (parts.size() >= 2 && parseDouble(parts[1], &num) && num > 0) { + return QString("%1 %2").arg(key, formatNumber(num)); + } + } + return kDefaultSlitwidth; +} + +static bool extractSetNumeric(const QString &text, double *valueOut) { + QStringList parts = splitTokens(text); + if (parts.size() >= 2 && parts[0].compare("SET", Qt::CaseInsensitive) == 0) { + double num = 0.0; + if (parseDouble(parts[1], &num) && num > 0) { + if (valueOut) *valueOut = num; + return true; + } + } + return false; +} + +static QStringList parseCsvLine(const QString &line) { + QStringList fields; + QString field; + bool inQuotes = false; + for (int i = 0; i < line.size(); ++i) { + const QChar ch = line.at(i); + if (ch == '"') { + if (inQuotes && i + 1 < line.size() && line.at(i + 1) == '"') { + field += '"'; + ++i; + } else { + inQuotes = !inQuotes; + } + } else if (ch == ',' && !inQuotes) { + fields << field.trimmed(); + field.clear(); + } else { + field += ch; + } + } + fields << field.trimmed(); + return fields; +} + +static QString csvEscape(const QString &value) { + if (value.contains(',') || value.contains('"') || value.contains('\n') || value.contains('\r')) { + QString escaped = value; + escaped.replace('"', "\"\""); + return QString("\"%1\"").arg(escaped); + } + return value; +} + +static QString normalizeOtmTimestamp(const QString &value) { + QString out = value.trimmed(); + if (out.isEmpty()) return out; + if (out.compare("None", Qt::CaseInsensitive) == 0) return QString(); + out.replace('T', ' '); + return out; +} + +static QDateTime parseUtcIso(const QString &text) { + QString trimmed = text.trimmed(); + if (trimmed.isEmpty()) return QDateTime(); + QDateTime dt = QDateTime::fromString(trimmed, Qt::ISODateWithMs); + if (!dt.isValid()) { + dt = QDateTime::fromString(trimmed, Qt::ISODate); + } + if (!dt.isValid()) return QDateTime(); + dt.setTimeSpec(Qt::UTC); + return dt; +} + +static int otmFlagSeverity(const QString &flag) { + const QString trimmed = flag.trimmed(); + if (trimmed.isEmpty()) return 0; + const QString upper = trimmed.toUpper(); + if (upper.contains("DAY-0-1") || upper.contains("DAY-1")) return 2; + QRegularExpression errRe("\\b[A-Z]+1\\b"); + if (errRe.match(upper).hasMatch()) return 2; + return 1; +} + +static QString mergeFlagText(const QString &existing, const QString &incoming) { + QStringList ordered; + QSet seen; + auto addTokens = [&](const QString &text) { + const QStringList tokens = text.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); + for (const QString &token : tokens) { + const QString upper = token.trimmed(); + if (upper.isEmpty()) continue; + if (seen.contains(upper)) continue; + seen.insert(upper); + ordered << upper; + } + }; + addTokens(existing); + addTokens(incoming); + return ordered.join(' '); +} + +static bool loadTimelineJson(const QString &path, TimelineData *data, QString *error) { + if (!data) return false; + QFile file(path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + if (error) *error = "Unable to read timeline data."; + return false; + } + const QByteArray raw = file.readAll(); + QJsonParseError parseError; + QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError); + if (doc.isNull()) { + if (error) *error = parseError.errorString(); + return false; + } + const QJsonObject root = doc.object(); + TimelineData tmp; + const QJsonArray times = root.value("times_utc").toArray(); + tmp.timesUtc.reserve(times.size()); + for (const QJsonValue &val : times) { + QDateTime dt = parseUtcIso(val.toString()); + if (dt.isValid()) tmp.timesUtc.append(dt); + } + + const QJsonObject twilight = root.value("twilight").toObject(); + tmp.twilightEvening16 = parseUtcIso(twilight.value("evening_16").toString()); + tmp.twilightEvening12 = parseUtcIso(twilight.value("evening_12").toString()); + tmp.twilightMorning12 = parseUtcIso(twilight.value("morning_12").toString()); + tmp.twilightMorning16 = parseUtcIso(twilight.value("morning_16").toString()); + tmp.delaySlewSec = root.value("delay_slew_sec").toDouble(0.0); + + const QJsonArray idle = root.value("idle_intervals").toArray(); + tmp.idleIntervals.reserve(idle.size()); + for (const QJsonValue &val : idle) { + const QJsonObject obj = val.toObject(); + const QDateTime s = parseUtcIso(obj.value("start").toString()); + const QDateTime e = parseUtcIso(obj.value("end").toString()); + if (s.isValid() && e.isValid() && e > s) { + tmp.idleIntervals.append({s, e}); + } + } + + const QJsonArray targets = root.value("targets").toArray(); + tmp.targets.reserve(targets.size()); + QHash targetIndexByKey; + for (const QJsonValue &val : targets) { + const QJsonObject obj = val.toObject(); + TimelineTarget target; + target.obsId = obj.value("obs_id").toString(); + target.name = obj.value("name").toString(); + target.startUtc = parseUtcIso(obj.value("start").toString()); + target.endUtc = parseUtcIso(obj.value("end").toString()); + target.slewGoUtc = parseUtcIso(obj.value("slew_go").toString()); + target.flag = obj.value("flag").toString(); + target.observed = obj.value("observed").toBool(); + if (!target.observed) { + target.observed = target.startUtc.isValid() && target.endUtc.isValid(); + } + target.waitSec = obj.value("wait_sec").toDouble(0.0); + target.severity = otmFlagSeverity(target.flag); + const QJsonArray airmass = obj.value("airmass").toArray(); + target.airmass.reserve(airmass.size()); + for (const QJsonValue &av : airmass) { + target.airmass.append(av.toDouble(std::numeric_limits::quiet_NaN())); + } + const QString key = !target.obsId.isEmpty() ? target.obsId : target.name; + if (key.isEmpty()) continue; + if (target.startUtc.isValid() && target.endUtc.isValid()) { + target.segments.append({target.startUtc, target.endUtc}); + } + if (!targetIndexByKey.contains(key)) { + targetIndexByKey.insert(key, tmp.targets.size()); + tmp.targets.append(target); + continue; + } + TimelineTarget &existing = tmp.targets[targetIndexByKey.value(key)]; + if (!target.obsId.isEmpty()) existing.obsId = target.obsId; + if (existing.name.isEmpty()) existing.name = target.name; + if (existing.airmass.isEmpty() && !target.airmass.isEmpty()) { + existing.airmass = target.airmass; + } + if (target.startUtc.isValid() && target.endUtc.isValid()) { + existing.segments.append({target.startUtc, target.endUtc}); + if (!existing.startUtc.isValid() || target.startUtc < existing.startUtc) { + existing.startUtc = target.startUtc; + } + if (!existing.endUtc.isValid() || target.endUtc > existing.endUtc) { + existing.endUtc = target.endUtc; + } + } + if (target.slewGoUtc.isValid() && !existing.slewGoUtc.isValid()) { + existing.slewGoUtc = target.slewGoUtc; + } + existing.waitSec = std::max(existing.waitSec, target.waitSec); + existing.observed = existing.observed || target.observed; + existing.severity = std::max(existing.severity, target.severity); + existing.flag = mergeFlagText(existing.flag, target.flag); + } + + *data = tmp; + return true; +} + +static void setNormalizedValue(QVariantMap &values, + QSet &nullColumns, + const QString &column, + const QString &newValue, + QStringList *changedColumns) { + const QString actualKey = findKeyCaseInsensitive(values, column); + const QString current = values.value(actualKey).toString(); + if (current != newValue) { + if (changedColumns) changedColumns->append(column); + } + values.insert(actualKey, newValue); + setRemoveCaseInsensitive(nullColumns, column); +} + +static NormalizationResult normalizeTargetRow(QVariantMap &values, QSet &nullColumns) { + NormalizationResult result; + + const QString name = values.value("NAME").toString().trimmed(); + const bool isCalib = name.toUpper().startsWith("CAL_"); + + if (containsKeyCaseInsensitive(values, "CHANNEL")) { + setNormalizedValue(values, nullColumns, "CHANNEL", + normalizeChannelValue(valueForKeyCaseInsensitive(values, "CHANNEL")), + &result.changedColumns); + } + + if (containsKeyCaseInsensitive(values, "SLITANGLE")) { + setNormalizedValue(values, nullColumns, "SLITANGLE", + normalizeSlitangleValue(valueForKeyCaseInsensitive(values, "SLITANGLE")), + &result.changedColumns); + } + + if (containsKeyCaseInsensitive(values, "EXPTIME")) { + const QString normalized = normalizeExptimeValue(valueForKeyCaseInsensitive(values, "EXPTIME"), isCalib); + setNormalizedValue(values, nullColumns, "EXPTIME", normalized, &result.changedColumns); + } + + if (containsKeyCaseInsensitive(values, "SLITWIDTH")) { + const QString normalized = normalizeSlitwidthValue(valueForKeyCaseInsensitive(values, "SLITWIDTH"), isCalib); + setNormalizedValue(values, nullColumns, "SLITWIDTH", normalized, &result.changedColumns); + } + + if (containsKeyCaseInsensitive(values, "BINSPECT")) { + int val = 0; + if (!parseInt(valueForKeyCaseInsensitive(values, "BINSPECT"), &val) || val <= 0) { + setNormalizedValue(values, nullColumns, "BINSPECT", QString::number(kDefaultBin), + &result.changedColumns); + } + } + + if (containsKeyCaseInsensitive(values, "NEXP")) { + int val = 0; + if (!parseInt(valueForKeyCaseInsensitive(values, "NEXP"), &val) || val <= 0) { + setNormalizedValue(values, nullColumns, "NEXP", "1", &result.changedColumns); + } + } + + if (containsKeyCaseInsensitive(values, "BINSPAT")) { + int val = 0; + if (!parseInt(valueForKeyCaseInsensitive(values, "BINSPAT"), &val) || val <= 0) { + setNormalizedValue(values, nullColumns, "BINSPAT", QString::number(kDefaultBin), + &result.changedColumns); + } + } + + if (containsKeyCaseInsensitive(values, "AIRMASS_MAX")) { + double val = 0.0; + if (!parseDouble(valueForKeyCaseInsensitive(values, "AIRMASS_MAX"), &val) || val < 1.0) { + setNormalizedValue(values, nullColumns, "AIRMASS_MAX", formatNumber(kDefaultAirmassMax), + &result.changedColumns); + } + } + + if (containsKeyCaseInsensitive(values, "POINTMODE")) { + setNormalizedValue(values, nullColumns, "POINTMODE", + normalizePointmodeValue(valueForKeyCaseInsensitive(values, "POINTMODE")), + &result.changedColumns); + } + + if (containsKeyCaseInsensitive(values, "CCDMODE")) { + setNormalizedValue(values, nullColumns, "CCDMODE", + normalizeCcdmodeValue(valueForKeyCaseInsensitive(values, "CCDMODE")), + &result.changedColumns); + } + + if (containsKeyCaseInsensitive(values, "MAGNITUDE")) { + double val = 0.0; + if (!parseDouble(valueForKeyCaseInsensitive(values, "MAGNITUDE"), &val)) { + setNormalizedValue(values, nullColumns, "MAGNITUDE", formatNumber(kDefaultMagnitude), + &result.changedColumns); + } + } + + if (containsKeyCaseInsensitive(values, "MAGSYSTEM")) { + setNormalizedValue(values, nullColumns, "MAGSYSTEM", + normalizeMagsystemValue(valueForKeyCaseInsensitive(values, "MAGSYSTEM")), + &result.changedColumns); + } + + if (containsKeyCaseInsensitive(values, "MAGFILTER")) { + setNormalizedValue(values, nullColumns, "MAGFILTER", + normalizeMagfilterValue(valueForKeyCaseInsensitive(values, "MAGFILTER")), + &result.changedColumns); + } + + if (containsKeyCaseInsensitive(values, "NOTBEFORE")) { + const QString val = valueForKeyCaseInsensitive(values, "NOTBEFORE").trimmed(); + if (val.isEmpty()) { + setNormalizedValue(values, nullColumns, "NOTBEFORE", kDefaultNotBefore, &result.changedColumns); + } + } + + const bool hasWrangeLow = containsKeyCaseInsensitive(values, "WRANGE_LOW"); + const bool hasWrangeHigh = containsKeyCaseInsensitive(values, "WRANGE_HIGH"); + if (hasWrangeLow || hasWrangeHigh) { + const QString channel = valueForKeyCaseInsensitive(values, "CHANNEL"); + double low = 0.0; + double high = 0.0; + bool lowOk = hasWrangeLow && parseDouble(valueForKeyCaseInsensitive(values, "WRANGE_LOW"), &low); + bool highOk = hasWrangeHigh && parseDouble(valueForKeyCaseInsensitive(values, "WRANGE_HIGH"), &high); + + if (!lowOk || !highOk || high <= low) { + const auto def = defaultWrangeForChannel(channel); + low = def.first; + high = def.second; + } + + if (hasWrangeLow) { + setNormalizedValue(values, nullColumns, "WRANGE_LOW", formatNumber(low), + &result.changedColumns); + } + if (hasWrangeHigh) { + setNormalizedValue(values, nullColumns, "WRANGE_HIGH", formatNumber(high), + &result.changedColumns); + } + } + + if (values.contains("EXPTIME") && isCalib) { + const QString exptime = valueForKeyCaseInsensitive(values, "EXPTIME"); + if (!exptime.toUpper().startsWith("SET")) { + setNormalizedValue(values, nullColumns, "EXPTIME", kDefaultExptime, &result.changedColumns); + } + } + if (containsKeyCaseInsensitive(values, "SLITWIDTH") && isCalib) { + const QString slitwidth = valueForKeyCaseInsensitive(values, "SLITWIDTH"); + if (!slitwidth.toUpper().startsWith("SET")) { + setNormalizedValue(values, nullColumns, "SLITWIDTH", kDefaultSlitwidth, &result.changedColumns); + } + } + + if (containsKeyCaseInsensitive(values, "OTMslitwidth")) { + const QString slitwidth = valueForKeyCaseInsensitive(values, "SLITWIDTH"); + double numeric = 0.0; + bool haveSet = extractSetNumeric(slitwidth, &numeric); + QString current = valueForKeyCaseInsensitive(values, "OTMslitwidth").trimmed(); + if (current.isEmpty()) { + const double toUse = haveSet ? numeric : kDefaultOtmSlitwidth; + setNormalizedValue(values, nullColumns, "OTMslitwidth", formatNumber(toUse), + &result.changedColumns); + } + } + + if (containsKeyCaseInsensitive(values, "OTMexpt")) { + const QString exptime = valueForKeyCaseInsensitive(values, "EXPTIME"); + double numeric = 0.0; + if (extractSetNumeric(exptime, &numeric)) { + setNormalizedValue(values, nullColumns, "OTMexpt", formatNumber(numeric), + &result.changedColumns); + } + } + + return result; +} + +struct ColumnMeta { + QString name; + QString type; + QString key; + QVariant defaultValue; + QString extra; + bool nullable = true; + + bool isPrimaryKey() const { return key.contains("PRI", Qt::CaseInsensitive); } + bool isAutoIncrement() const { return extra.contains("auto_increment", Qt::CaseInsensitive); } + bool hasDefault() const { return !defaultValue.isNull(); } + bool isDateTime() const { + const QString t = type.toLower(); + return t.contains("timestamp") || t.contains("datetime"); + } +}; + +struct DbConfig { + QString host; + int port = 33060; + QString user; + QString pass; + QString schema; + QString tableTargets; + QString tableSets; + + bool isComplete() const { + return !host.isEmpty() && !user.isEmpty() && !schema.isEmpty() && + !tableTargets.isEmpty() && !tableSets.isEmpty(); + } +}; + +static QString stripInlineComment(const QString &line) { + int idx = line.indexOf('#'); + if (idx >= 0) { + return line.left(idx).trimmed(); + } + return line.trimmed(); +} + +static DbConfig loadConfigFile(const QString &path) { + DbConfig cfg; + QFile file(path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + return cfg; + } + QTextStream in(&file); + while (!in.atEnd()) { + QString line = in.readLine().trimmed(); + if (line.isEmpty() || line.startsWith('#')) { + continue; + } + line = stripInlineComment(line); + int eq = line.indexOf('='); + if (eq <= 0) { + continue; + } + const QString key = line.left(eq).trimmed(); + const QString value = line.mid(eq + 1).trimmed(); + if (key == "DB_HOST") cfg.host = value; + else if (key == "DB_PORT") cfg.port = value.toInt(); + else if (key == "DB_USER") cfg.user = value; + else if (key == "DB_PASS") cfg.pass = value; + else if (key == "DB_SCHEMA") cfg.schema = value; + else if (key == "DB_ACTIVE") cfg.tableTargets = value.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts).value(0); + else if (key == "DB_SETS") cfg.tableSets = value.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts).value(0); + } + return cfg; +} + +static QString detectDefaultConfigPath() { + if (qEnvironmentVariableIsSet("NGPS_CONFIG")) { + return qEnvironmentVariable("NGPS_CONFIG"); + } + if (qEnvironmentVariableIsSet("NGPS_ROOT")) { + const QString root = qEnvironmentVariable("NGPS_ROOT"); + const QString candidate = QDir(root).filePath("Config/sequencerd.cfg"); + if (QFile::exists(candidate)) return candidate; + } + const QString appDir = QCoreApplication::applicationDirPath(); + QDir dir(appDir); + for (int i = 0; i < 6; ++i) { + const QString candidate = dir.filePath("Config/sequencerd.cfg"); + if (QFile::exists(candidate)) return candidate; + if (!dir.cdUp()) break; + } + const QString cwd = QDir::currentPath(); + const QString direct = QDir(cwd).filePath("Config/sequencerd.cfg"); + if (QFile::exists(direct)) return direct; + const QString upOne = QDir(cwd).filePath("../Config/sequencerd.cfg"); + if (QFile::exists(upOne)) return upOne; + return QString(); +} + +static QString inferNgpsRootFromConfig(const QString &configPath) { + QFileInfo info(configPath); + if (!info.exists()) return QString(); + QDir dir = info.dir(); + if (dir.dirName().toLower() == "config") { + dir.cdUp(); + return dir.absolutePath(); + } + return info.absoluteDir().absolutePath(); +} + +static QVariant mysqlValueToVariant(const mysqlx::Value &value) { + using mysqlx::Value; + switch (value.getType()) { + case Value::VNULL: + return QVariant(); + case Value::INT64: + return QVariant::fromValue(value.get()); + case Value::UINT64: + return QVariant::fromValue(value.get()); + case Value::FLOAT: + return QVariant::fromValue(value.get()); + case Value::DOUBLE: + return QVariant::fromValue(value.get()); + case Value::BOOL: + return QVariant::fromValue(value.get()); + case Value::STRING: + return QString::fromStdString(value.get()); + case Value::RAW: { + mysqlx::bytes raw = value.get(); + QByteArray bytes(reinterpret_cast(raw.first), + static_cast(raw.second)); + bool printable = true; + for (unsigned char ch : bytes) { + if (ch == 0) { printable = false; break; } + if (!std::isprint(ch) && !std::isspace(ch)) { printable = false; break; } + } + if (printable) { + return QString::fromUtf8(bytes); + } + return bytes; + } + case Value::ARRAY: { + std::ostringstream stream; + stream << value; + return QString::fromStdString(stream.str()); + } + case Value::DOCUMENT: + return QString::fromStdString(value.get()); + } + return QVariant(); +} + +static QString displayForVariant(const QVariant &value, bool isNull) { + if (isNull) { + return "NULL"; + } + if (value.userType() == QMetaType::QByteArray) { + return value.toByteArray().toHex(' ').toUpper(); + } + return value.toString(); +} + +static bool isLongMessage(const QString &text) { + const int maxChars = 600; + const int maxLines = 20; + return text.size() > maxChars || text.count('\n') > maxLines; +} + +static void showMessageDialog(QWidget *parent, + QMessageBox::Icon icon, + const QString &title, + const QString &text) { + if (!isLongMessage(text)) { + QMessageBox box(parent); + box.setIcon(icon); + box.setWindowTitle(title); + box.setText(text); + box.setStandardButtons(QMessageBox::Ok); + box.exec(); + return; + } + + QDialog dialog(parent); + dialog.setWindowTitle(title); + dialog.setModal(true); + + QVBoxLayout *layout = new QVBoxLayout(&dialog); + QHBoxLayout *header = new QHBoxLayout(); + + QLabel *iconLabel = new QLabel(&dialog); + QIcon iconObj = QApplication::style()->standardIcon( + icon == QMessageBox::Warning ? QStyle::SP_MessageBoxWarning : + icon == QMessageBox::Critical ? QStyle::SP_MessageBoxCritical : + QStyle::SP_MessageBoxInformation); + iconLabel->setPixmap(iconObj.pixmap(32, 32)); + header->addWidget(iconLabel); + + QLabel *titleLabel = new QLabel("Message is long. See details below.", &dialog); + titleLabel->setWordWrap(true); + header->addWidget(titleLabel, 1); + layout->addLayout(header); + + QPlainTextEdit *details = new QPlainTextEdit(text, &dialog); + details->setReadOnly(true); + details->setLineWrapMode(QPlainTextEdit::NoWrap); + layout->addWidget(details, 1); + + QDialogButtonBox *buttons = new QDialogButtonBox(QDialogButtonBox::Ok, &dialog); + QObject::connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); + layout->addWidget(buttons); + + QScreen *screen = parent ? parent->screen() : QGuiApplication::primaryScreen(); + QRect avail = screen ? screen->availableGeometry() : QRect(0, 0, 1200, 800); + dialog.resize(int(avail.width() * 0.8), int(avail.height() * 0.8)); + dialog.exec(); +} + +static void showWarning(QWidget *parent, const QString &title, const QString &text) { + showMessageDialog(parent, QMessageBox::Warning, title, text); +} + +static void showInfo(QWidget *parent, const QString &title, const QString &text) { + showMessageDialog(parent, QMessageBox::Information, title, text); +} + +static void showError(QWidget *parent, const QString &title, const QString &text) { + showMessageDialog(parent, QMessageBox::Critical, title, text); +} + +class ReorderTableView : public QTableView { + Q_OBJECT +public: + explicit ReorderTableView(QWidget *parent = nullptr) : QTableView(parent) {} + void setAllowDeleteShortcut(bool enabled) { allowDeleteShortcut_ = enabled; } + void setIconHitTest(const std::function &fn) { + iconHitTest_ = fn; + } + +signals: + void dragSwapRequested(int sourceRow, int targetRow); + void cellClicked(const QModelIndex &index, const QPoint &pos); + void deleteRequested(); + +protected: + void paintEvent(QPaintEvent *event) override { + QTableView::paintEvent(event); + if (!dragging_ || dragTargetRow_ < 0 || !model()) return; + const QModelIndex idx = model()->index(dragTargetRow_, 0); + if (!idx.isValid()) return; + QRect rowRect = visualRect(idx); + if (!rowRect.isValid()) return; + QPainter painter(viewport()); + QColor lineColor(90, 160, 255); + QPen pen(lineColor, 2); + painter.setPen(pen); + const int y = rowRect.bottom(); + painter.drawLine(0, y, viewport()->width(), y); + } + + void mousePressEvent(QMouseEvent *event) override { + if (event->button() == Qt::LeftButton) { + pressPos_ = event->pos(); + pressRow_ = indexAt(pressPos_).row(); + dragging_ = false; + dragTargetRow_ = -1; + const QModelIndex index = indexAt(event->pos()); + if (index.isValid() && iconHitTest_ && iconHitTest_(index, event->pos())) { + suppressReleaseClick_ = true; + suppressDoubleClick_ = true; + if (selectionModel()) { + selectionModel()->setCurrentIndex( + index, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + } + emit cellClicked(index, event->pos()); + event->accept(); + return; + } + } + QTableView::mousePressEvent(event); + } + + void mouseMoveEvent(QMouseEvent *event) override { + if (!(event->buttons() & Qt::LeftButton) || pressRow_ < 0) { + QTableView::mouseMoveEvent(event); + return; + } + if (!dragging_) { + if ((event->pos() - pressPos_).manhattanLength() < QApplication::startDragDistance()) { + QTableView::mouseMoveEvent(event); + return; + } + dragging_ = true; + setCursor(Qt::ClosedHandCursor); + } + int targetRow = indexAt(event->pos()).row(); + if (targetRow < 0 && model()) { + if (event->pos().y() < 0) { + targetRow = 0; + } else if (event->pos().y() > viewport()->height()) { + targetRow = model()->rowCount() - 1; + } + } + if (targetRow != dragTargetRow_) { + dragTargetRow_ = targetRow; + viewport()->update(); + } + QTableView::mouseMoveEvent(event); + } + + void mouseReleaseEvent(QMouseEvent *event) override { + if (dragging_ && event->button() == Qt::LeftButton) { + int targetRow = indexAt(event->pos()).row(); + if (targetRow < 0 && model()) { + targetRow = model()->rowCount() - 1; + } + if (pressRow_ >= 0 && targetRow >= 0 && pressRow_ != targetRow) { + emit dragSwapRequested(pressRow_, targetRow); + } + } + dragging_ = false; + pressRow_ = -1; + dragTargetRow_ = -1; + viewport()->update(); + unsetCursor(); + QTableView::mouseReleaseEvent(event); + if (event->button() == Qt::LeftButton) { + if (suppressReleaseClick_) { + suppressReleaseClick_ = false; + return; + } + const QModelIndex index = indexAt(event->pos()); + if (index.isValid()) { + emit cellClicked(index, event->pos()); + } + } + } + + void mouseDoubleClickEvent(QMouseEvent *event) override { + if (suppressDoubleClick_) { + suppressDoubleClick_ = false; + event->accept(); + return; + } + const QModelIndex index = indexAt(event->pos()); + if (event->button() == Qt::LeftButton && index.isValid() && iconHitTest_) { + if (iconHitTest_(index, event->pos())) { + event->accept(); + return; + } + } + QTableView::mouseDoubleClickEvent(event); + } + + void keyPressEvent(QKeyEvent *event) override { + if (allowDeleteShortcut_ && + (event->key() == Qt::Key_Delete || event->key() == Qt::Key_Backspace)) { + if (state() != QAbstractItemView::EditingState) { + emit deleteRequested(); + event->accept(); + return; + } + } + QTableView::keyPressEvent(event); + } + +private: + QPoint pressPos_; + int pressRow_ = -1; + bool dragging_ = false; + int dragTargetRow_ = -1; + bool allowDeleteShortcut_ = false; + std::function iconHitTest_; + bool suppressReleaseClick_ = false; + bool suppressDoubleClick_ = false; +}; + +class DbClient { +public: + bool connect(const DbConfig &cfg, QString *error) { + try { + session_ = std::make_unique( + mysqlx::SessionOption::HOST, cfg.host.toStdString(), + mysqlx::SessionOption::PORT, cfg.port, + mysqlx::SessionOption::USER, cfg.user.toStdString(), + mysqlx::SessionOption::PWD, cfg.pass.toStdString(), + mysqlx::SessionOption::DB, cfg.schema.toStdString()); + logLine(QString("CONNECTED %1@%2:%3/%4") + .arg(cfg.user) + .arg(cfg.host) + .arg(cfg.port) + .arg(cfg.schema)); + schemaName_ = cfg.schema; + connected_ = true; + return true; + } catch (const mysqlx::Error &e) { + if (error) *error = QString::fromStdString(e.what()); + } catch (...) { + if (error) *error = "Unknown database error"; + } + connected_ = false; + return false; + } + + void close() { + if (session_) { + try { + session_->close(); + logLine("DISCONNECTED"); + } catch (...) { + } + } + session_.reset(); + connected_ = false; + } + + bool isOpen() const { return connected_ && session_ != nullptr; } + + bool loadColumns(const QString &tableName, QList &columns, QString *error) { + columns.clear(); + if (!isOpen()) { + if (error) *error = "Not connected"; + return false; + } + try { + const std::string sql = "SHOW COLUMNS FROM `" + tableName.toStdString() + "`"; + logSql(QString::fromStdString(sql), {}); + mysqlx::SqlResult result = session_->sql(sql).execute(); + for (mysqlx::Row row : result) { + if (row.colCount() < 6) continue; + ColumnMeta meta; + meta.name = QString::fromStdString(row[0].get()); + meta.type = QString::fromStdString(row[1].get()); + meta.nullable = QString::fromStdString(row[2].get()).toUpper() == "YES"; + meta.key = QString::fromStdString(row[3].get()); + if (row[4].getType() == mysqlx::Value::VNULL) { + meta.defaultValue = QVariant(); + } else { + meta.defaultValue = mysqlValueToVariant(row[4]); + } + meta.extra = QString::fromStdString(row[5].get()); + columns.append(meta); + } + return !columns.isEmpty(); + } catch (const mysqlx::Error &e) { + if (error) *error = QString::fromStdString(e.what()); + } catch (...) { + if (error) *error = "Failed to read columns"; + } + return false; + } + + bool fetchRows(const QString &tableName, + const QList &columns, + const QString &fixedFilterColumn, + const QString &fixedFilterValue, + const QString &searchColumn, + const QString &searchValue, + const QString &orderByColumn, + QVector> &rows, + QString *error) { + rows.clear(); + if (!isOpen()) { + if (error) *error = "Not connected"; + return false; + } + try { + QStringList selectCols; + selectCols.reserve(columns.size()); + for (const ColumnMeta &meta : columns) { + if (meta.isDateTime()) { + selectCols << QString("CAST(`%1` AS CHAR) AS `%1`").arg(meta.name); + } else { + selectCols << QString("`%1`").arg(meta.name); + } + } + QString sql = QString("SELECT %1 FROM `%2`").arg(selectCols.join(", "), tableName); + QStringList conditions; + QList binds; + if (!fixedFilterColumn.isEmpty() && !fixedFilterValue.isEmpty()) { + conditions << QString("`%1` = ?").arg(fixedFilterColumn); + binds << fixedFilterValue; + } + if (!searchColumn.isEmpty() && !searchValue.isEmpty()) { + conditions << QString("LOWER(`%1`) LIKE ?").arg(searchColumn); + binds << QString("%%%1%%").arg(searchValue.toLower()); + } + if (!conditions.isEmpty()) { + sql += " WHERE " + conditions.join(" AND "); + } + if (!orderByColumn.isEmpty()) { + sql += QString(" ORDER BY `%1`").arg(orderByColumn); + } + mysqlx::SqlStatement stmt = session_->sql(sql.toStdString()); + logSql(sql, binds); + for (const QString &bind : binds) { + stmt.bind(bind.toStdString()); + } + mysqlx::SqlResult result = stmt.execute(); + for (mysqlx::Row row : result) { + QVector rowValues; + rowValues.reserve(static_cast(row.colCount())); + for (mysqlx::col_count_t i = 0; i < row.colCount(); ++i) { + rowValues.append(mysqlValueToVariant(row[i])); + } + rows.append(rowValues); + } + return true; + } catch (const mysqlx::Error &e) { + if (error) *error = QString::fromStdString(e.what()); + } catch (...) { + if (error) *error = "Failed to read rows"; + } + return false; + } + + bool insertRecord(const QString &tableName, + const QList &columns, + const QVariantMap &values, + const QSet &nullColumns, + QString *error) { + if (!isOpen()) { + if (error) *error = "Not connected"; + return false; + } + + QStringList cols; + QStringList vals; + QList binds; + + for (const ColumnMeta &meta : columns) { + QVariant raw; + bool hasValue = values.contains(meta.name); + if (!hasValue) { + const QString actualKey = findKeyCaseInsensitive(values, meta.name); + if (values.contains(actualKey)) { + raw = values.value(actualKey); + hasValue = true; + } + } else { + raw = values.value(meta.name); + } + const QString text = raw.toString(); + const bool hasText = !text.isEmpty(); + + if (meta.isAutoIncrement() && !hasText && !setContainsCaseInsensitive(nullColumns, meta.name)) { + continue; + } + + if (setContainsCaseInsensitive(nullColumns, meta.name)) { + cols << QString("`%1`").arg(meta.name); + vals << "NULL"; + continue; + } + + if (!hasText) { + if (meta.hasDefault()) { + continue; + } + if (meta.isDateTime()) { + cols << QString("`%1`").arg(meta.name); + vals << "?"; + binds << QDateTime::currentDateTime().toString("yyyy-MM-dd HH:mm:ss.zzz"); + continue; + } + if (meta.nullable) { + cols << QString("`%1`").arg(meta.name); + vals << "NULL"; + continue; + } + if (error) *error = QString("Missing required value for %1").arg(meta.name); + return false; + } + + cols << QString("`%1`").arg(meta.name); + vals << "?"; + binds << text; + } + + if (cols.isEmpty()) { + if (error) *error = "No values to insert"; + return false; + } + + const QString sql = QString("INSERT INTO `%1` (%2) VALUES (%3)") + .arg(tableName) + .arg(cols.join(", ")) + .arg(vals.join(", ")); + try { + logLine("START TRANSACTION"); + logSql(sql, toStringList(binds)); + session_->startTransaction(); + mysqlx::SqlStatement stmt = session_->sql(sql.toStdString()); + for (const QVariant &val : binds) { + stmt.bind(val.toString().toStdString()); + } + stmt.execute(); + session_->commit(); + logLine("COMMIT"); + return true; + } catch (const mysqlx::Error &e) { + try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} + if (error) *error = QString::fromStdString(e.what()); + } catch (...) { + try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} + if (error) *error = "Insert failed"; + } + return false; + } + + bool updateRecord(const QString &tableName, + const QList &columns, + const QVariantMap &values, + const QSet &nullColumns, + const QVariantMap &keyValues, + QString *error) { + if (!isOpen()) { + if (error) *error = "Not connected"; + return false; + } + + QStringList sets; + QList binds; + + for (const ColumnMeta &meta : columns) { + QVariant raw; + bool hasValue = values.contains(meta.name); + if (!hasValue) { + const QString actualKey = findKeyCaseInsensitive(values, meta.name); + if (values.contains(actualKey)) { + raw = values.value(actualKey); + hasValue = true; + } + } else { + raw = values.value(meta.name); + } + const QString text = raw.toString(); + const bool hasText = !text.isEmpty(); + + if (setContainsCaseInsensitive(nullColumns, meta.name)) { + sets << QString("`%1`=NULL").arg(meta.name); + continue; + } + + if (!hasText) { + if (meta.nullable) { + sets << QString("`%1`=?").arg(meta.name); + binds << QString(); + continue; + } + if (error) *error = QString("Missing required value for %1").arg(meta.name); + return false; + } + + sets << QString("`%1`=?").arg(meta.name); + binds << text; + } + + if (sets.isEmpty()) { + if (error) *error = "No values to update"; + return false; + } + + QStringList where; + for (auto it = keyValues.begin(); it != keyValues.end(); ++it) { + where << QString("`%1`=?").arg(it.key()); + binds << it.value(); + } + + if (where.isEmpty()) { + if (error) *error = "Missing primary key values"; + return false; + } + + const QString sql = QString("UPDATE `%1` SET %2 WHERE %3") + .arg(tableName) + .arg(sets.join(", ")) + .arg(where.join(" AND ")); + try { + logLine("START TRANSACTION"); + logSql(sql, toStringList(binds)); + session_->startTransaction(); + mysqlx::SqlStatement stmt = session_->sql(sql.toStdString()); + for (const QVariant &val : binds) { + stmt.bind(val.toString().toStdString()); + } + stmt.execute(); + session_->commit(); + logLine("COMMIT"); + return true; + } catch (const mysqlx::Error &e) { + try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} + if (error) *error = QString::fromStdString(e.what()); + } catch (...) { + try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} + if (error) *error = "Update failed"; + } + return false; + } + + bool updateColumnByKeyBatch(const QString &tableName, + const QString &columnName, + const QList &keyValuesList, + const QList &values, + QString *error) { + if (!isOpen()) { + if (error) *error = "Not connected"; + return false; + } + if (keyValuesList.size() != values.size()) { + if (error) *error = "Batch size mismatch"; + return false; + } + try { + logLine("START TRANSACTION"); + session_->startTransaction(); + for (int i = 0; i < keyValuesList.size(); ++i) { + const QVariantMap &keyValues = keyValuesList.at(i); + if (keyValues.isEmpty()) { + session_->rollback(); + if (error) *error = "Missing primary key values"; + return false; + } + QStringList where; + QList binds; + binds << values.at(i).toString(); + for (auto it = keyValues.begin(); it != keyValues.end(); ++it) { + where << QString("`%1`=?").arg(it.key()); + binds << it.value().toString(); + } + const QString sql = QString("UPDATE `%1` SET `%2`=? WHERE %3") + .arg(tableName, columnName, where.join(" AND ")); + mysqlx::SqlStatement stmt = session_->sql(sql.toStdString()); + logSql(sql, binds); + for (const QString &bind : binds) { + stmt.bind(bind.toStdString()); + } + stmt.execute(); + } + session_->commit(); + logLine("COMMIT"); + return true; + } catch (const mysqlx::Error &e) { + try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} + if (error) *error = QString::fromStdString(e.what()); + } catch (...) { + try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} + if (error) *error = "Batch update failed"; + } + return false; + } + + bool updateObsOrderByObservationId(const QString &tableName, + const QList &obsIds, + const QList &orderValues, + QString *error) { + if (!isOpen()) { + if (error) *error = "Not connected"; + return false; + } + if (obsIds.size() != orderValues.size() || obsIds.isEmpty()) { + if (error) *error = "Invalid OBS_ORDER update data"; + return false; + } + try { + logLine("START TRANSACTION"); + session_->startTransaction(); + + long long maxOrder = 0; + try { + const QString maxSql = QString("SELECT MAX(`OBS_ORDER`) FROM `%1`").arg(tableName); + logSql(maxSql, {}); + mysqlx::SqlResult maxRes = session_->sql(maxSql.toStdString()).execute(); + mysqlx::Row maxRow = maxRes.fetchOne(); + if (maxRow && maxRow.colCount() > 0 && maxRow[0].getType() != mysqlx::Value::VNULL) { + maxOrder = maxRow[0].get(); + } + } catch (...) { + maxOrder = 0; + } + const long long offset = maxOrder + 1000; + + QStringList inParts; + inParts.reserve(obsIds.size()); + for (int i = 0; i < obsIds.size(); ++i) { + inParts << "?"; + } + + const QString bumpSql = QString("UPDATE `%1` SET `OBS_ORDER` = `OBS_ORDER` + ? WHERE `OBSERVATION_ID` IN (%2)") + .arg(tableName, inParts.join(", ")); + mysqlx::SqlStatement bumpStmt = session_->sql(bumpSql.toStdString()); + QList bumpBinds; + bumpBinds << QString::number(offset); + for (const QVariant &obsId : obsIds) bumpBinds << obsId.toString(); + logSql(bumpSql, bumpBinds); + bumpStmt.bind(QString::number(offset).toStdString()); + for (const QVariant &obsId : obsIds) { + bumpStmt.bind(obsId.toString().toStdString()); + } + bumpStmt.execute(); + + QStringList caseParts; + caseParts.reserve(obsIds.size()); + for (int i = 0; i < obsIds.size(); ++i) { + caseParts << "WHEN ? THEN ?"; + } + const QString finalSql = QString("UPDATE `%1` SET `OBS_ORDER` = CASE `OBSERVATION_ID` %2 END WHERE `OBSERVATION_ID` IN (%3)") + .arg(tableName, caseParts.join(" "), inParts.join(", ")); + mysqlx::SqlStatement finalStmt = session_->sql(finalSql.toStdString()); + QList finalBinds; + for (int i = 0; i < obsIds.size(); ++i) { + finalBinds << obsIds.at(i).toString() << orderValues.at(i).toString(); + } + for (const QVariant &obsId : obsIds) { + finalBinds << obsId.toString(); + } + logSql(finalSql, finalBinds); + for (int i = 0; i < obsIds.size(); ++i) { + finalStmt.bind(obsIds.at(i).toString().toStdString()); + finalStmt.bind(orderValues.at(i).toString().toStdString()); + } + for (const QVariant &obsId : obsIds) { + finalStmt.bind(obsId.toString().toStdString()); + } + finalStmt.execute(); + session_->commit(); + logLine("COMMIT"); + return true; + } catch (const mysqlx::Error &e) { + try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} + if (error) *error = QString::fromStdString(e.what()); + } catch (...) { + try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} + if (error) *error = "OBS_ORDER update failed"; + } + return false; + } + + bool updateObsOrderByCompositeKey(const QString &tableName, + const QList &obsIds, + const QList &slitWidths, + const QList &orderValues, + QString *error) { + if (!isOpen()) { + if (error) *error = "Not connected"; + return false; + } + if (obsIds.size() != orderValues.size() || obsIds.size() != slitWidths.size() || obsIds.isEmpty()) { + if (error) *error = "Invalid OBS_ORDER update data"; + return false; + } + try { + logLine("START TRANSACTION"); + session_->startTransaction(); + + long long maxOrder = 0; + try { + const QString maxSql = QString("SELECT MAX(`OBS_ORDER`) FROM `%1`").arg(tableName); + logSql(maxSql, {}); + mysqlx::SqlResult maxRes = session_->sql(maxSql.toStdString()).execute(); + mysqlx::Row maxRow = maxRes.fetchOne(); + if (maxRow && maxRow.colCount() > 0 && maxRow[0].getType() != mysqlx::Value::VNULL) { + maxOrder = maxRow[0].get(); + } + } catch (...) { + maxOrder = 0; + } + const long long offset = maxOrder + 1000; + + QStringList tupleParts; + tupleParts.reserve(obsIds.size()); + for (int i = 0; i < obsIds.size(); ++i) { + tupleParts << "(?, ?)"; + } + + const QString bumpSql = QString("UPDATE `%1` SET `OBS_ORDER` = `OBS_ORDER` + ? " + "WHERE (`OBSERVATION_ID`, `OTMslitwidth`) IN (%2)") + .arg(tableName, tupleParts.join(", ")); + mysqlx::SqlStatement bumpStmt = session_->sql(bumpSql.toStdString()); + QList bumpBinds; + bumpBinds << QString::number(offset); + for (int i = 0; i < obsIds.size(); ++i) { + bumpBinds << obsIds.at(i).toString() << slitWidths.at(i).toString(); + } + logSql(bumpSql, bumpBinds); + bumpStmt.bind(QString::number(offset).toStdString()); + for (int i = 0; i < obsIds.size(); ++i) { + bumpStmt.bind(obsIds.at(i).toString().toStdString()); + bumpStmt.bind(slitWidths.at(i).toString().toStdString()); + } + bumpStmt.execute(); + + QStringList caseParts; + caseParts.reserve(obsIds.size()); + for (int i = 0; i < obsIds.size(); ++i) { + caseParts << "WHEN `OBSERVATION_ID`=? AND `OTMslitwidth`=? THEN ?"; + } + const QString finalSql = QString("UPDATE `%1` SET `OBS_ORDER` = CASE %2 END " + "WHERE (`OBSERVATION_ID`, `OTMslitwidth`) IN (%3)") + .arg(tableName, caseParts.join(" "), tupleParts.join(", ")); + mysqlx::SqlStatement finalStmt = session_->sql(finalSql.toStdString()); + QList finalBinds; + for (int i = 0; i < obsIds.size(); ++i) { + finalBinds << obsIds.at(i).toString() << slitWidths.at(i).toString() + << orderValues.at(i).toString(); + } + for (int i = 0; i < obsIds.size(); ++i) { + finalBinds << obsIds.at(i).toString() << slitWidths.at(i).toString(); + } + logSql(finalSql, finalBinds); + // CASE bindings + for (int i = 0; i < obsIds.size(); ++i) { + finalStmt.bind(obsIds.at(i).toString().toStdString()); + finalStmt.bind(slitWidths.at(i).toString().toStdString()); + finalStmt.bind(orderValues.at(i).toString().toStdString()); + } + // WHERE tuple bindings + for (int i = 0; i < obsIds.size(); ++i) { + finalStmt.bind(obsIds.at(i).toString().toStdString()); + finalStmt.bind(slitWidths.at(i).toString().toStdString()); + } + finalStmt.execute(); + + session_->commit(); + logLine("COMMIT"); + return true; + } catch (const mysqlx::Error &e) { + try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} + if (error) *error = QString::fromStdString(e.what()); + } catch (...) { + try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} + if (error) *error = "OBS_ORDER update failed"; + } + return false; + } + + bool moveObsOrder(const QString &tableName, + int setId, + const QVariant &obsId, + const QVariant &slitWidth, + int oldPos, + int newPos, + QString *error) { + if (!isOpen()) { + if (error) *error = "Not connected"; + return false; + } + if (oldPos == newPos) return true; + try { + logLine("START TRANSACTION"); + session_->startTransaction(); + if (newPos < oldPos) { + const QString shiftSql = QString("UPDATE `%1` SET `OBS_ORDER` = `OBS_ORDER` + 1 " + "WHERE `SET_ID` = ? AND `OBS_ORDER` >= ? AND `OBS_ORDER` < ?") + .arg(tableName); + mysqlx::SqlStatement shiftStmt = session_->sql(shiftSql.toStdString()); + logSql(shiftSql, {QString::number(setId), QString::number(newPos), QString::number(oldPos)}); + shiftStmt.bind(QString::number(setId).toStdString()); + shiftStmt.bind(QString::number(newPos).toStdString()); + shiftStmt.bind(QString::number(oldPos).toStdString()); + shiftStmt.execute(); + } else { + const QString shiftSql = QString("UPDATE `%1` SET `OBS_ORDER` = `OBS_ORDER` - 1 " + "WHERE `SET_ID` = ? AND `OBS_ORDER` <= ? AND `OBS_ORDER` > ?") + .arg(tableName); + mysqlx::SqlStatement shiftStmt = session_->sql(shiftSql.toStdString()); + logSql(shiftSql, {QString::number(setId), QString::number(newPos), QString::number(oldPos)}); + shiftStmt.bind(QString::number(setId).toStdString()); + shiftStmt.bind(QString::number(newPos).toStdString()); + shiftStmt.bind(QString::number(oldPos).toStdString()); + shiftStmt.execute(); + } + + const QString updateSql = QString("UPDATE `%1` SET `OBS_ORDER` = ? " + "WHERE `OBSERVATION_ID` = ? AND `OTMslitwidth` = ?") + .arg(tableName); + mysqlx::SqlStatement updateStmt = session_->sql(updateSql.toStdString()); + logSql(updateSql, {QString::number(newPos), obsId.toString(), slitWidth.toString()}); + updateStmt.bind(QString::number(newPos).toStdString()); + updateStmt.bind(obsId.toString().toStdString()); + updateStmt.bind(slitWidth.toString().toStdString()); + updateStmt.execute(); + + session_->commit(); + logLine("COMMIT"); + return true; + } catch (const mysqlx::Error &e) { + try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} + if (error) *error = QString::fromStdString(e.what()); + } catch (...) { + try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} + if (error) *error = "OBS_ORDER move failed"; + } + return false; + } + + bool swapTargets(const QString &tableName, + const QVariant &obsIdX, + const QVariant &orderX, + const QVariant &obsIdY, + const QVariant &orderY, + QString *error) { + if (!isOpen()) { + if (error) *error = "Not connected"; + return false; + } + if (obsIdX == obsIdY) return true; + try { + logLine("START TRANSACTION"); + session_->startTransaction(); + + long long maxObsId = 0; + long long maxOrder = 0; + try { + const QString maxObsSql = QString("SELECT MAX(`OBSERVATION_ID`) FROM `%1`").arg(tableName); + logSql(maxObsSql, {}); + mysqlx::SqlResult maxObsRes = session_->sql(maxObsSql.toStdString()).execute(); + mysqlx::Row maxObsRow = maxObsRes.fetchOne(); + if (maxObsRow && maxObsRow.colCount() > 0 && maxObsRow[0].getType() != mysqlx::Value::VNULL) { + maxObsId = maxObsRow[0].get(); + } + } catch (...) { + maxObsId = 0; + } + try { + const QString maxOrderSql = QString("SELECT MAX(`OBS_ORDER`) FROM `%1`").arg(tableName); + logSql(maxOrderSql, {}); + mysqlx::SqlResult maxOrderRes = session_->sql(maxOrderSql.toStdString()).execute(); + mysqlx::Row maxOrderRow = maxOrderRes.fetchOne(); + if (maxOrderRow && maxOrderRow.colCount() > 0 && maxOrderRow[0].getType() != mysqlx::Value::VNULL) { + maxOrder = maxOrderRow[0].get(); + } + } catch (...) { + maxOrder = 0; + } + const long long tempObsId = maxObsId + 100000; + const long long tempOrder = maxOrder + 100000; + + const QString bumpSql = QString("UPDATE `%1` SET `OBSERVATION_ID`=?, `OBS_ORDER`=? " + "WHERE `OBSERVATION_ID`=?") + .arg(tableName); + logSql(bumpSql, {QString::number(tempObsId), QString::number(tempOrder), + obsIdY.toString()}); + mysqlx::SqlStatement bumpStmt = session_->sql(bumpSql.toStdString()); + bumpStmt.bind(QString::number(tempObsId).toStdString()); + bumpStmt.bind(QString::number(tempOrder).toStdString()); + bumpStmt.bind(obsIdY.toString().toStdString()); + bumpStmt.execute(); + + const QString updateXSql = QString("UPDATE `%1` SET `OBSERVATION_ID`=?, `OBS_ORDER`=? " + "WHERE `OBSERVATION_ID`=?") + .arg(tableName); + logSql(updateXSql, {obsIdY.toString(), orderY.toString(), + obsIdX.toString()}); + mysqlx::SqlStatement updateX = session_->sql(updateXSql.toStdString()); + updateX.bind(obsIdY.toString().toStdString()); + updateX.bind(orderY.toString().toStdString()); + updateX.bind(obsIdX.toString().toStdString()); + updateX.execute(); + + const QString updateYSql = QString("UPDATE `%1` SET `OBSERVATION_ID`=?, `OBS_ORDER`=? " + "WHERE `OBSERVATION_ID`=?") + .arg(tableName); + logSql(updateYSql, {obsIdX.toString(), orderX.toString(), + QString::number(tempObsId)}); + mysqlx::SqlStatement updateY = session_->sql(updateYSql.toStdString()); + updateY.bind(obsIdX.toString().toStdString()); + updateY.bind(orderX.toString().toStdString()); + updateY.bind(QString::number(tempObsId).toStdString()); + updateY.execute(); + + session_->commit(); + logLine("COMMIT"); + return true; + } catch (const mysqlx::Error &e) { + try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} + if (error) *error = QString::fromStdString(e.what()); + } catch (...) { + try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} + if (error) *error = "Swap failed"; + } + return false; + } + + bool shiftObsOrderAfterDelete(const QString &tableName, + int setId, + int deletedPos, + QString *error) { + if (!isOpen()) { + if (error) *error = "Not connected"; + return false; + } + try { + const QString sql = QString("UPDATE `%1` SET `OBS_ORDER` = `OBS_ORDER` - 1 " + "WHERE `SET_ID` = ? AND `OBS_ORDER` > ?") + .arg(tableName); + mysqlx::SqlStatement stmt = session_->sql(sql.toStdString()); + logSql(sql, {QString::number(setId), QString::number(deletedPos)}); + stmt.bind(QString::number(setId).toStdString()); + stmt.bind(QString::number(deletedPos).toStdString()); + stmt.execute(); + return true; + } catch (const mysqlx::Error &e) { + if (error) *error = QString::fromStdString(e.what()); + } catch (...) { + if (error) *error = "OBS_ORDER shift failed"; + } + return false; + } + + bool shiftObsOrderForInsert(const QString &tableName, + int setId, + int startPos, + int count, + QString *error) { + if (!isOpen()) { + if (error) *error = "Not connected"; + return false; + } + if (count <= 0) return true; + try { + const QString sql = QString("UPDATE `%1` SET `OBS_ORDER` = `OBS_ORDER` + ? " + "WHERE `SET_ID` = ? AND `OBS_ORDER` >= ?") + .arg(tableName); + mysqlx::SqlStatement stmt = session_->sql(sql.toStdString()); + logSql(sql, {QString::number(count), QString::number(setId), QString::number(startPos)}); + stmt.bind(QString::number(count).toStdString()); + stmt.bind(QString::number(setId).toStdString()); + stmt.bind(QString::number(startPos).toStdString()); + stmt.execute(); + return true; + } catch (const mysqlx::Error &e) { + if (error) *error = QString::fromStdString(e.what()); + } catch (...) { + if (error) *error = "OBS_ORDER shift failed"; + } + return false; + } + + bool nextObsOrderForSet(const QString &tableName, + int setId, + int *nextOrder, + QString *error) { + if (!isOpen()) { + if (error) *error = "Not connected"; + return false; + } + if (!nextOrder) { + if (error) *error = "Missing output parameter"; + return false; + } + try { + const QString sql = QString("SELECT MAX(`OBS_ORDER`) FROM `%1` WHERE `SET_ID` = ?") + .arg(tableName); + logSql(sql, {QString::number(setId)}); + mysqlx::SqlStatement stmt = session_->sql(sql.toStdString()); + stmt.bind(QString::number(setId).toStdString()); + mysqlx::SqlResult res = stmt.execute(); + mysqlx::Row row = res.fetchOne(); + long long maxOrder = 0; + if (row && row.colCount() > 0 && row[0].getType() != mysqlx::Value::VNULL) { + maxOrder = row[0].get(); + } + *nextOrder = static_cast(maxOrder + 1); + return true; + } catch (const mysqlx::Error &e) { + if (error) *error = QString::fromStdString(e.what()); + } catch (...) { + if (error) *error = "Failed to read OBS_ORDER"; + } + return false; + } + + bool deleteRecordByKey(const QString &tableName, + const QVariantMap &keyValues, + QString *error) { + if (!isOpen()) { + if (error) *error = "Not connected"; + return false; + } + if (keyValues.isEmpty()) { + if (error) *error = "Missing primary key values"; + return false; + } + try { + QStringList where; + QList binds; + for (auto it = keyValues.begin(); it != keyValues.end(); ++it) { + where << QString("`%1`=?").arg(it.key()); + binds << it.value().toString(); + } + const QString sql = QString("DELETE FROM `%1` WHERE %2") + .arg(tableName, where.join(" AND ")); + mysqlx::SqlStatement stmt = session_->sql(sql.toStdString()); + logSql(sql, binds); + for (const QString &bind : binds) { + stmt.bind(bind.toStdString()); + } + stmt.execute(); + return true; + } catch (const mysqlx::Error &e) { + if (error) *error = QString::fromStdString(e.what()); + } catch (...) { + if (error) *error = "Delete failed"; + } + return false; + } + + bool deleteRecordsByColumn(const QString &tableName, + const QString &columnName, + const QVariant &value, + QString *error) { + if (!isOpen()) { + if (error) *error = "Not connected"; + return false; + } + try { + const QString sql = QString("DELETE FROM `%1` WHERE `%2`=?") + .arg(tableName, columnName); + mysqlx::SqlStatement stmt = session_->sql(sql.toStdString()); + logSql(sql, {value.toString()}); + stmt.bind(value.toString().toStdString()); + stmt.execute(); + return true; + } catch (const mysqlx::Error &e) { + if (error) *error = QString::fromStdString(e.what()); + } catch (...) { + if (error) *error = "Delete failed"; + } + return false; + } + + bool fetchSingleValue(const QString &sql, + const QList &binds, + QVariant *valueOut, + QString *error) { + if (!isOpen()) { + if (error) *error = "Not connected"; + return false; + } + try { + mysqlx::SqlStatement stmt = session_->sql(sql.toStdString()); + logSql(sql, toStringList(binds)); + for (const QVariant &val : binds) { + stmt.bind(val.toString().toStdString()); + } + mysqlx::SqlResult result = stmt.execute(); + for (mysqlx::Row row : result) { + if (row.colCount() > 0) { + if (valueOut) *valueOut = mysqlValueToVariant(row[0]); + return true; + } + break; + } + if (error) *error = "No results"; + } catch (const mysqlx::Error &e) { + if (error) *error = QString::fromStdString(e.what()); + } catch (...) { + if (error) *error = "Query failed"; + } + return false; + } + + bool updateColumnsByKey(const QString &tableName, + const QVariantMap &updates, + const QVariantMap &keyValues, + QString *error) { + if (!isOpen()) { + if (error) *error = "Not connected"; + return false; + } + if (updates.isEmpty()) { + if (error) *error = "No columns to update"; + return false; + } + if (keyValues.isEmpty()) { + if (error) *error = "Missing primary key values"; + return false; + } + try { + QStringList sets; + QList binds; + for (auto it = updates.begin(); it != updates.end(); ++it) { + const QVariant val = it.value(); + if (!val.isValid() || val.isNull()) { + sets << QString("`%1`=NULL").arg(it.key()); + } else { + sets << QString("`%1`=?").arg(it.key()); + binds << val; + } + } + QStringList where; + for (auto it = keyValues.begin(); it != keyValues.end(); ++it) { + where << QString("`%1`=?").arg(it.key()); + binds << it.value(); + } + const QString sql = QString("UPDATE `%1` SET %2 WHERE %3") + .arg(tableName) + .arg(sets.join(", ")) + .arg(where.join(" AND ")); + logLine("START TRANSACTION"); + logSql(sql, toStringList(binds)); + session_->startTransaction(); + mysqlx::SqlStatement stmt = session_->sql(sql.toStdString()); + for (const QVariant &val : binds) { + stmt.bind(val.toString().toStdString()); + } + stmt.execute(); + session_->commit(); + logLine("COMMIT"); + return true; + } catch (const mysqlx::Error &e) { + try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} + if (error) *error = QString::fromStdString(e.what()); + } catch (...) { + try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} + if (error) *error = "Update failed"; + } + return false; + } + +private: + std::unique_ptr session_; + QString schemaName_; + bool connected_ = false; + bool logSql_ = true; + + void logLine(const QString &line) const { + if (!logSql_) return; + qInfo().noquote() << line; + } + + static QString quoteBind(const QString &value) { + QString v = value; + v.replace('\'', "''"); + return "'" + v + "'"; + } + + static QString expandSql(const QString &sql, const QList &binds) { + QString out; + out.reserve(sql.size() + binds.size() * 4); + int bindIndex = 0; + for (QChar ch : sql) { + if (ch == '?' && bindIndex < binds.size()) { + out += quoteBind(binds.at(bindIndex++)); + } else { + out += ch; + } + } + if (bindIndex < binds.size()) { + QStringList extra; + for (int i = bindIndex; i < binds.size(); ++i) { + extra << quoteBind(binds.at(i)); + } + out += QString(" /* extra binds: %1 */").arg(extra.join(", ")); + } + return out; + } + + static QList toStringList(const QList &vars) { + QList out; + out.reserve(vars.size()); + for (const QVariant &v : vars) { + out << v.toString(); + } + return out; + } + + void logSql(const QString &sql, const QList &binds) const { + if (!logSql_) return; + qInfo().noquote() << "SQL:" << expandSql(sql, binds); + } +}; + +class RecordEditorDialog : public QDialog { + Q_OBJECT +public: + RecordEditorDialog(const QString &tableName, + const QList &columns, + const QVariantMap &initialValues, + bool isInsert, + QWidget *parent = nullptr) + : QDialog(parent), columns_(columns) { + setWindowTitle(isInsert ? QString("Add %1").arg(tableName) + : QString("Edit %1").arg(tableName)); + setModal(true); + + QVBoxLayout *layout = new QVBoxLayout(this); + QFormLayout *form = new QFormLayout(); + + for (const ColumnMeta &meta : columns_) { + QWidget *fieldWidget = new QWidget(this); + QHBoxLayout *fieldLayout = new QHBoxLayout(fieldWidget); + fieldLayout->setContentsMargins(0, 0, 0, 0); + + QLineEdit *edit = new QLineEdit(fieldWidget); + QCheckBox *nullCheck = nullptr; + + const QVariant val = initialValues.value(meta.name); + if (val.isValid() && !val.isNull()) { + edit->setText(val.toString()); + } else if (!val.isValid() || val.isNull()) { + edit->setText(QString()); + } + + if (meta.nullable) { + nullCheck = new QCheckBox("NULL", fieldWidget); + if (!val.isValid() || val.isNull()) { + nullCheck->setChecked(true); + edit->setEnabled(false); + } + connect(nullCheck, &QCheckBox::toggled, edit, &QWidget::setDisabled); + } + + if (isInsert && meta.isAutoIncrement() && edit->text().isEmpty()) { + edit->setPlaceholderText("AUTO"); + } + + fieldLayout->addWidget(edit, 1); + if (nullCheck) fieldLayout->addWidget(nullCheck); + + QString label = meta.name + " (" + meta.type + ")"; + if (!meta.nullable) label += " *"; + form->addRow(label, fieldWidget); + + edits_.insert(meta.name, edit); + if (nullCheck) nullChecks_.insert(meta.name, nullCheck); + } + + layout->addLayout(form); + + QHBoxLayout *buttons = new QHBoxLayout(); + buttons->addStretch(); + QPushButton *cancel = new QPushButton("Cancel", this); + QPushButton *ok = new QPushButton("Save", this); + connect(cancel, &QPushButton::clicked, this, &QDialog::reject); + connect(ok, &QPushButton::clicked, this, &QDialog::accept); + buttons->addWidget(cancel); + buttons->addWidget(ok); + layout->addLayout(buttons); + } + + QVariantMap values() const { + QVariantMap map; + for (const ColumnMeta &meta : columns_) { + QLineEdit *edit = edits_.value(meta.name); + if (!edit) continue; + map.insert(meta.name, edit->text()); + } + return map; + } + + QSet nullColumns() const { + QSet cols; + for (auto it = nullChecks_.begin(); it != nullChecks_.end(); ++it) { + if (it.value()->isChecked()) { + cols.insert(it.key()); + } + } + return cols; + } + +private: + QList columns_; + QHash edits_; + QHash nullChecks_; +}; + +struct OtmSettings { + double seeingFwhm = 1.1; + double seeingPivot = 500.0; + double airmassMax = 4.0; + bool useSkySim = true; + QString pythonCmd; +}; + +class OtmSettingsDialog : public QDialog { + Q_OBJECT +public: + explicit OtmSettingsDialog(const OtmSettings &initial, QWidget *parent = nullptr) + : QDialog(parent) { + setWindowTitle("OTM Settings"); + setModal(true); + + QVBoxLayout *layout = new QVBoxLayout(this); + QFormLayout *form = new QFormLayout(); + + pythonEdit_ = new QLineEdit(initial.pythonCmd, this); + pythonEdit_->setPlaceholderText("auto-detect (python3)"); + pythonEdit_->setToolTip("Optional. Leave blank to auto-detect."); + form->addRow("Python (OTM)", pythonEdit_); + + seeingFwhm_ = new QDoubleSpinBox(this); + seeingFwhm_->setRange(0.1, 10.0); + seeingFwhm_->setDecimals(3); + seeingFwhm_->setValue(initial.seeingFwhm); + form->addRow("Seeing FWHM (arcsec)", seeingFwhm_); + + seeingPivot_ = new QDoubleSpinBox(this); + seeingPivot_->setRange(100.0, 2000.0); + seeingPivot_->setDecimals(1); + seeingPivot_->setValue(initial.seeingPivot); + form->addRow("Seeing Pivot (nm)", seeingPivot_); + + airmassMax_ = new QDoubleSpinBox(this); + airmassMax_->setRange(1.0, 10.0); + airmassMax_->setDecimals(2); + airmassMax_->setValue(initial.airmassMax); + form->addRow("Airmass Max", airmassMax_); + + useSkySim_ = new QCheckBox("Use sky simulation", this); + useSkySim_->setChecked(initial.useSkySim); + form->addRow(useSkySim_); + + layout->addLayout(form); + + QHBoxLayout *buttons = new QHBoxLayout(); + buttons->addStretch(); + QPushButton *cancel = new QPushButton("Cancel", this); + QPushButton *ok = new QPushButton("Run", this); + ok->setDefault(true); + ok->setAutoDefault(true); + cancel->setAutoDefault(false); + connect(cancel, &QPushButton::clicked, this, &QDialog::reject); + connect(ok, &QPushButton::clicked, this, &QDialog::accept); + buttons->addWidget(cancel); + buttons->addWidget(ok); + layout->addLayout(buttons); + } + + OtmSettings settings() const { + OtmSettings s; + s.seeingFwhm = seeingFwhm_->value(); + s.seeingPivot = seeingPivot_->value(); + s.airmassMax = airmassMax_->value(); + s.useSkySim = useSkySim_->isChecked(); + s.pythonCmd = pythonEdit_->text().trimmed(); + return s; + } + +private: + QLineEdit *pythonEdit_ = nullptr; + QDoubleSpinBox *seeingFwhm_ = nullptr; + QDoubleSpinBox *seeingPivot_ = nullptr; + QDoubleSpinBox *airmassMax_ = nullptr; + QCheckBox *useSkySim_ = nullptr; +}; + +class TablePanel : public QWidget { + Q_OBJECT +public: + TablePanel(const QString &title, QWidget *parent = nullptr) + : QWidget(parent) { + QVBoxLayout *layout = new QVBoxLayout(this); + + QHBoxLayout *topBar = new QHBoxLayout(); + QLabel *titleLabel = new QLabel("" + title + "", this); + topBar->addWidget(titleLabel); + topBar->addStretch(); + + refreshButton_ = new QPushButton("Refresh", this); + addButton_ = new QPushButton("Add", this); + topBar->addWidget(refreshButton_); + topBar->addWidget(addButton_); + + layout->addLayout(topBar); + + QHBoxLayout *filterBar = new QHBoxLayout(); + searchLabel_ = new QLabel("Search:", this); + searchEdit_ = new QLineEdit(this); + searchApply_ = new QPushButton("Search", this); + searchClear_ = new QPushButton("Clear", this); + searchLabel_->setVisible(false); + searchEdit_->setVisible(false); + searchApply_->setVisible(false); + searchClear_->setVisible(false); + + filterBar->addWidget(searchLabel_); + filterBar->addWidget(searchEdit_); + filterBar->addWidget(searchApply_); + filterBar->addWidget(searchClear_); + + filterBar->addStretch(); + + layout->addLayout(filterBar); + + model_ = new QStandardItemModel(this); + view_ = new ReorderTableView(this); + view_->setModel(model_); + view_->setSelectionBehavior(QAbstractItemView::SelectRows); + view_->setSelectionMode(QAbstractItemView::SingleSelection); + view_->setEditTriggers(QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed); + view_->horizontalHeader()->setStretchLastSection(false); + view_->horizontalHeader()->setSectionResizeMode(QHeaderView::Interactive); + view_->setSortingEnabled(sortingEnabled_); + view_->setContextMenuPolicy(Qt::CustomContextMenu); + view_->setAllowDeleteShortcut(allowDelete_); + layout->addWidget(view_, 1); + + statusLabel_ = new QLabel("Not connected", this); + layout->addWidget(statusLabel_); + + connect(refreshButton_, &QPushButton::clicked, this, &TablePanel::refresh); + connect(addButton_, &QPushButton::clicked, this, &TablePanel::addRecord); + connect(searchApply_, &QPushButton::clicked, this, &TablePanel::refresh); + connect(searchClear_, &QPushButton::clicked, this, &TablePanel::clearSearch); + connect(view_, &QWidget::customContextMenuRequested, this, &TablePanel::showContextMenu); + connect(view_, &ReorderTableView::dragSwapRequested, this, &TablePanel::handleDragSwap); + connect(view_, &ReorderTableView::cellClicked, this, &TablePanel::handleCellClick); + connect(view_, &ReorderTableView::deleteRequested, this, &TablePanel::handleDeleteShortcut); + connect(model_, &QStandardItemModel::itemChanged, this, &TablePanel::handleItemChanged); + view_->horizontalHeader()->setContextMenuPolicy(Qt::CustomContextMenu); + connect(view_->horizontalHeader(), &QHeaderView::customContextMenuRequested, + this, &TablePanel::showColumnHeaderContextMenu); + view_->horizontalHeader()->installEventFilter(this); + if (view_->horizontalHeader()->viewport()) { + view_->horizontalHeader()->viewport()->installEventFilter(this); + } + connect(view_->selectionModel(), &QItemSelectionModel::currentRowChanged, this, + [this](const QModelIndex &, const QModelIndex &) { emit selectionChanged(); }); + view_->setIconHitTest([this](const QModelIndex &index, const QPoint &pos) { + if (!groupingEnabled_) return false; + if (!index.isValid()) return false; + const int nameCol = columnIndex("NAME"); + if (nameCol < 0 || index.column() != nameCol) return false; + const int row = index.row(); + if (!isGroupHeaderRow(row)) return false; + QRect cellRect = view_->visualRect(index); + const int iconSize = view_->style()->pixelMetric(QStyle::PM_SmallIconSize); + QRect iconRect(cellRect.left() + 4, + cellRect.center().y() - iconSize / 2, + iconSize, iconSize); + return iconRect.contains(pos); + }); + + headerSaveTimer_ = new QTimer(this); + headerSaveTimer_->setSingleShot(true); + connect(headerSaveTimer_, &QTimer::timeout, this, &TablePanel::saveHeaderState); + QHeaderView *header = view_->horizontalHeader(); + connect(header, &QHeaderView::sectionResized, this, + [this](int, int, int) { scheduleHeaderStateSave(); }); + connect(header, &QHeaderView::sectionMoved, this, + [this](int, int, int) { scheduleHeaderStateSave(); }); + } + + ~TablePanel() override { + if (headerSaveTimer_ && headerSaveTimer_->isActive()) { + headerSaveTimer_->stop(); + } + saveHeaderState(); + } + + void setDatabase(DbClient *db, const QString &tableName) { + db_ = db; + tableName_ = tableName; + columns_.clear(); + headerStateLoaded_ = false; + groupingStateLoaded_ = false; + manualUngroupObsIds_.clear(); + manualGroupKeyByObsId_.clear(); + refresh(); + } + + void setSearchColumn(const QString &columnName) { + searchColumn_ = columnName; + const bool enabled = !searchColumn_.isEmpty(); + searchLabel_->setVisible(enabled); + searchEdit_->setVisible(enabled); + searchApply_->setVisible(enabled); + searchClear_->setVisible(enabled); + if (enabled) { + searchLabel_->setText(QString("Search %1:").arg(searchColumn_)); + } + } + + void setFixedFilter(const QString &columnName, const QString &value) { + fixedFilterColumn_ = columnName; + fixedFilterValue_ = value; + } + + void clearFixedFilter() { + fixedFilterColumn_.clear(); + fixedFilterValue_.clear(); + } + + QString fixedFilterColumn() const { return fixedFilterColumn_; } + QString fixedFilterValue() const { return fixedFilterValue_; } + + void setOrderByColumn(const QString &columnName) { + orderByColumn_ = columnName; + } + + void setSortingEnabled(bool enabled) { + sortingEnabled_ = enabled; + if (view_) view_->setSortingEnabled(sortingEnabled_); + } + + void setAllowReorder(bool enabled) { + allowReorder_ = enabled; + if (!view_) return; + if (allowReorder_) { + view_->setDragDropMode(QAbstractItemView::NoDragDrop); + view_->setDragEnabled(false); + view_->setAcceptDrops(false); + view_->setDropIndicatorShown(false); + } else { + view_->setDragDropMode(QAbstractItemView::NoDragDrop); + view_->setDragEnabled(false); + view_->setAcceptDrops(false); + } + } + + void setAllowDelete(bool enabled) { + allowDelete_ = enabled; + if (view_) view_->setAllowDeleteShortcut(enabled); + } + + void setAllowColumnHeaderBulkEdit(bool enabled) { allowColumnHeaderBulkEdit_ = enabled; } + + void setRowNormalizer(const std::function &)> &normalizer) { + normalizer_ = normalizer; + } + + void setQuickAddEnabled(bool enabled) { quickAddEnabled_ = enabled; } + + void setQuickAddBuilder(const std::function &, QString *)> &builder) { + quickAddBuilder_ = builder; + } + + void setQuickAddInsertAtTop(bool enabled) { quickAddInsertAtTop_ = enabled; } + + void setGroupingEnabled(bool enabled) { + groupingEnabled_ = enabled; + applyGrouping(); + } + + void setHiddenColumns(const QStringList &columns) { + hiddenColumns_.clear(); + for (const QString &name : columns) { + hiddenColumns_ << name.toUpper(); + } + applyHiddenColumns(); + } + + void showContextMenuForObsId(const QString &obsId, const QPoint &globalPos) { + if (obsId.isEmpty()) return; + const int row = findRowByColumnValue("OBSERVATION_ID", obsId); + if (row < 0) return; + showContextMenuAtRow(row, globalPos); + } + + void setColumnAfterRules(const QVector> &rules) { + columnAfterRules_.clear(); + for (const auto &rule : rules) { + columnAfterRules_.append({rule.first.toUpper(), rule.second.toUpper()}); + } + headerRulesPending_ = true; + applyColumnOrderRules(); + } + + QVariantMap currentRowValues() const { + QVariantMap map; + const QModelIndex current = view_->currentIndex(); + if (!current.isValid()) return map; + const int row = current.row(); + for (int col = 0; col < columns_.size(); ++col) { + QStandardItem *item = model_->item(row, col); + if (!item) continue; + map.insert(columns_[col].name, item->data(Qt::EditRole)); + } + return map; + } + + bool selectRowByColumnValue(const QString &columnName, const QVariant &value) { + const int col = columnIndex(columnName); + if (col < 0) return false; + for (int row = 0; row < model_->rowCount(); ++row) { + QStandardItem *item = model_->item(row, col); + if (!item) continue; + const QVariant cellValue = item->data(Qt::UserRole + 1); + if (cellValue.toString() == value.toString()) { + const QModelIndex idx = model_->index(row, 0); + view_->selectionModel()->setCurrentIndex( + idx, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + return true; + } + } + return false; + } + + QSet ungroupedObsIds() const { return manualUngroupObsIds_; } + + bool updateColumnForObsIds(const QStringList &obsIds, const QString &column, const QString &value, + QStringList *errors = nullptr) { + if (!db_ || !db_->isOpen()) { + if (errors) errors->append("Not connected"); + return false; + } + if (obsIds.isEmpty()) return false; + QVariantMap updates; + updates.insert(column, value); + bool ok = true; + for (const QString &obsId : obsIds) { + QVariantMap keyValues; + keyValues.insert("OBSERVATION_ID", obsId); + QString error; + if (!db_->updateColumnsByKey(tableName_, updates, keyValues, &error)) { + ok = false; + if (errors) { + errors->append(QString("%1: %2").arg(obsId, error.isEmpty() ? "Update failed" : error)); + } + } + } + if (ok) { + refreshWithState(captureViewState()); + emit dataMutated(); + } + return ok; + } + + QHash groupMembersByHeaderObsId() const { + QHash result; + if (!groupingEnabled_) return result; + const int obsIdCol = columnIndex("OBSERVATION_ID"); + if (obsIdCol < 0) return result; + for (auto it = groupRowsByKey_.begin(); it != groupRowsByKey_.end(); ++it) { + const QString key = it.key(); + const int headerRow = groupHeaderRowByKey_.value(key, -1); + if (headerRow < 0) continue; + QStandardItem *headerItem = model_->item(headerRow, obsIdCol); + if (!headerItem) continue; + const QString headerObsId = headerItem->data(Qt::UserRole + 1).toString(); + if (headerObsId.isEmpty()) continue; + QStringList members; + for (int row : it.value()) { + QStandardItem *obsItem = model_->item(row, obsIdCol); + if (!obsItem) continue; + const QString obsId = obsItem->data(Qt::UserRole + 1).toString(); + if (!obsId.isEmpty()) members.append(obsId); + } + if (!members.isEmpty()) { + result.insert(headerObsId, members); + } + } + return result; + } + + QVariant valueForColumnInRow(const QString &matchColumn, + const QVariant &matchValue, + const QString &columnName) const { + const int matchCol = columnIndex(matchColumn); + const int valueCol = columnIndex(columnName); + if (matchCol < 0 || valueCol < 0) return QVariant(); + for (int row = 0; row < model_->rowCount(); ++row) { + QStandardItem *matchItem = model_->item(row, matchCol); + if (!matchItem) continue; + const QVariant cellValue = matchItem->data(Qt::UserRole + 1); + if (cellValue.toString() == matchValue.toString()) { + QStandardItem *valueItem = model_->item(row, valueCol); + if (!valueItem) return QVariant(); + return valueItem->data(Qt::UserRole + 1); + } + } + return QVariant(); + } + + bool hasColumn(const QString &name) const { + return columnIndex(name) >= 0; + } + + QVariantMap currentKeyValues() const { + QVariantMap map; + const QModelIndex current = view_->currentIndex(); + if (!current.isValid()) return map; + const int row = current.row(); + for (int col = 0; col < columns_.size(); ++col) { + if (!columns_[col].isPrimaryKey()) continue; + QStandardItem *item = model_->item(row, col); + if (!item) continue; + map.insert(columns_[col].name, item->data(Qt::UserRole + 1)); + } + return map; + } + + bool moveObsAfter(const QString &fromObsId, const QString &toObsId, QString *error = nullptr) { + const int fromRow = findRowByColumnValue("OBSERVATION_ID", fromObsId); + const int toRow = findRowByColumnValue("OBSERVATION_ID", toObsId); + if (fromRow < 0 || toRow < 0) { + if (error) *error = "Target not found in view."; + return false; + } + return moveRowAfter(fromRow, toRow, error); + } + + bool moveGroupAfterObsId(const QString &fromObsId, const QString &toObsId, QString *error = nullptr) { + const int fromRow = findRowByColumnValue("OBSERVATION_ID", fromObsId); + const int toRow = findRowByColumnValue("OBSERVATION_ID", toObsId); + if (fromRow < 0 || toRow < 0) { + if (error) *error = "Target not found in view."; + return false; + } + if (!db_ || !db_->isOpen()) { + if (error) *error = "Not connected"; + return false; + } + ViewState state = captureViewState(); + QString err; + bool ok = false; + if (groupingEnabled_) { + ok = moveGroupAfterRow(fromRow, toRow, &err); + } else { + const int setIdCol = columnIndex("SET_ID"); + int setId = -1; + if (setIdCol >= 0) { + QStandardItem *setItem = model_->item(fromRow, setIdCol); + if (setItem) setId = setItem->data(Qt::UserRole + 1).toInt(); + } + ok = moveSingleAfterRow(fromRow, toRow, setId, &err); + } + if (!ok) { + if (error) *error = err.isEmpty() ? "Move failed." : err; + return false; + } + refreshWithState(state); + emit dataMutated(); + return true; + } + + bool moveGroupToTopObsId(const QString &fromObsId, QString *error = nullptr) { + const int fromRow = findRowByColumnValue("OBSERVATION_ID", fromObsId); + if (fromRow < 0) { + if (error) *error = "Target not found in view."; + return false; + } + if (!db_ || !db_->isOpen()) { + if (error) *error = "Not connected"; + return false; + } + ViewState state = captureViewState(); + QString err; + bool ok = false; + if (groupingEnabled_) { + ok = moveGroupToTopRow(fromRow, &err); + } else { + const int setIdCol = columnIndex("SET_ID"); + int setId = -1; + if (setIdCol >= 0) { + QStandardItem *setItem = model_->item(fromRow, setIdCol); + if (setItem) setId = setItem->data(Qt::UserRole + 1).toInt(); + } + ok = moveSingleToTopRow(fromRow, setId, &err); + } + if (!ok) { + if (error) *error = err.isEmpty() ? "Move failed." : err; + return false; + } + refreshWithState(state); + emit dataMutated(); + return true; + } + + void persistHeaderState() { saveHeaderState(); } + + QList columns() const { return columns_; } + +public slots: + void refresh() { + refreshWithState(captureViewState()); + } + + void addRecord() { + if (!db_ || !db_->isOpen()) return; + if (quickAddEnabled_ && quickAddBuilder_) { + QVariantMap values; + QSet nullColumns; + QString buildError; + if (!quickAddBuilder_(values, nullColumns, &buildError)) { + showWarning(this, "Add failed", buildError.isEmpty() ? "Unable to add target." : buildError); + return; + } + if (normalizer_) { + normalizer_(values, nullColumns); + } + QString error; + ViewState state = captureViewState(); + if (quickAddInsertAtTop_) { + bool okSet = false; + bool okOrder = false; + const int setId = values.value("SET_ID").toInt(&okSet); + const int obsOrder = values.value("OBS_ORDER").toInt(&okOrder); + if (okSet && okOrder && setId > 0 && obsOrder > 0) { + QString shiftErr; + if (!db_->shiftObsOrderForInsert(tableName_, setId, obsOrder, 1, &shiftErr)) { + showWarning(this, "Add failed", + shiftErr.isEmpty() ? "Failed to insert at top." : shiftErr); + return; + } + } + } + if (!insertRecord(values, nullColumns, &error)) { + showWarning(this, "Add failed", error); + return; + } + refreshWithState(state); + if (groupingEnabled_) { + const int setId = values.value("SET_ID").toInt(); + const int obsOrder = values.value("OBS_ORDER").toInt(); + if (setId > 0 && obsOrder > 0) { + const QStringList newObsIds = obsIdsForObsOrderRange(setId, obsOrder, 1); + if (!newObsIds.isEmpty()) { + for (const QString &newObsId : newObsIds) { + manualUngroupObsIds_.insert(newObsId); + manualGroupKeyByObsId_.remove(newObsId); + } + saveGroupingState(); + applyGrouping(); + } + } + } + emit dataMutated(); + return; + } + RecordEditorDialog dialog(tableName_, columns_, QVariantMap(), true, this); + bool inserted = false; + if (dialog.exec() == QDialog::Accepted) { + QVariantMap values = dialog.values(); + QSet nullColumns = dialog.nullColumns(); + if (normalizer_) { + normalizer_(values, nullColumns); + } + QString error; + if (!insertRecord(values, nullColumns, &error)) { + showWarning(this, "Insert failed", error); + } else { + inserted = true; + } + } + refresh(); + if (inserted) emit dataMutated(); + } + + void clearSearch() { + searchEdit_->clear(); + refresh(); + } + +signals: + void selectionChanged(); + void dataMutated(); + +private slots: + void handleItemChanged(QStandardItem *item) { + if (suppressItemChange_) return; + if (!db_ || !db_->isOpen()) return; + if (!item) return; + + const int row = item->row(); + const int col = item->column(); + if (row < 0 || col < 0 || col >= columns_.size()) return; + + ColumnMeta meta = columns_.at(col); + const QVariant oldValue = item->data(Qt::UserRole + 1); + const bool oldIsNull = item->data(Qt::UserRole + 2).toBool(); + + QString text = item->text().trimmed(); + bool newIsNull = false; + QVariant newValue; + if (text.compare("NULL", Qt::CaseInsensitive) == 0 || + (text.isEmpty() && meta.nullable)) { + newIsNull = true; + newValue = QVariant(); + } else { + newIsNull = false; + newValue = text; + } + + if (newIsNull && !meta.nullable) { + showWarning(this, "Update failed", + QString("%1 cannot be NULL").arg(meta.name)); + revertItem(item, oldValue, oldIsNull); + return; + } + if (!newIsNull && text.isEmpty() && !meta.nullable) { + showWarning(this, "Update failed", + QString("%1 is required").arg(meta.name)); + revertItem(item, oldValue, oldIsNull); + return; + } + + QVariantMap values; + QSet nullColumns; + for (int c = 0; c < columns_.size(); ++c) { + ColumnMeta m = columns_.at(c); + QStandardItem *rowItem = model_->item(row, c); + QVariant value = rowItem ? rowItem->data(Qt::EditRole) : QVariant(); + bool isNull = rowItem ? rowItem->data(Qt::UserRole + 2).toBool() : true; + + if (c == col) { + isNull = newIsNull; + value = newValue; + } + + values.insert(m.name, value); + if (isNull) nullColumns.insert(m.name); + } + + NormalizationResult norm; + if (normalizer_) { + norm = normalizer_(values, nullColumns); + } + + QVariantMap keyValues; + for (int c = 0; c < columns_.size(); ++c) { + if (!columns_[c].isPrimaryKey()) continue; + QStandardItem *rowItem = model_->item(row, c); + if (!rowItem) continue; + const QVariant keyValue = rowItem->data(Qt::UserRole + 1); + const bool keyIsNull = rowItem->data(Qt::UserRole + 2).toBool(); + if (!keyValue.isValid() || keyIsNull) { + showWarning(this, "Update failed", + QString("Primary key %1 is NULL").arg(columns_[c].name)); + revertItem(item, oldValue, oldIsNull); + return; + } + keyValues.insert(columns_[c].name, keyValue); + } + + QString error; + if (!db_->updateRecord(tableName_, columns_, values, nullColumns, keyValues, &error)) { + showWarning(this, "Update failed", error); + revertItem(item, oldValue, oldIsNull); + return; + } + + const QVariant normalizedValue = values.value(meta.name); + const bool normalizedIsNull = nullColumns.contains(meta.name); + + suppressItemChange_ = true; + item->setData(normalizedIsNull ? QVariant() : normalizedValue, Qt::EditRole); + item->setData(normalizedIsNull ? QVariant() : normalizedValue, Qt::UserRole + 1); + item->setData(normalizedIsNull, Qt::UserRole + 2); + item->setText(displayForVariant(normalizedValue, normalizedIsNull)); + item->setForeground(QBrush(normalizedIsNull ? view_->palette().color(QPalette::Disabled, QPalette::Text) + : view_->palette().color(QPalette::Text))); + + for (const QString &colName : norm.changedColumns) { + const int colIndex = columnIndex(colName); + if (colIndex < 0 || colIndex >= columns_.size()) continue; + if (colIndex == col) continue; + QStandardItem *targetItem = model_->item(row, colIndex); + if (!targetItem) continue; + const QVariant cellValue = values.value(colName); + const bool cellIsNull = nullColumns.contains(colName); + targetItem->setData(cellIsNull ? QVariant() : cellValue, Qt::EditRole); + targetItem->setData(cellIsNull ? QVariant() : cellValue, Qt::UserRole + 1); + targetItem->setData(cellIsNull, Qt::UserRole + 2); + targetItem->setText(displayForVariant(cellValue, cellIsNull)); + targetItem->setForeground(QBrush(cellIsNull ? view_->palette().color(QPalette::Disabled, QPalette::Text) + : view_->palette().color(QPalette::Text))); + } + suppressItemChange_ = false; + applyGrouping(); + emit dataMutated(); + } + + void handleDragSwap(int sourceRow, int targetRow) { + moveRowAfter(sourceRow, targetRow, nullptr); + } + + void handleDeleteShortcut() { + if (!allowDelete_) return; + const int row = view_->currentIndex().row(); + if (row < 0) return; + deleteRow(row); + } + + bool eventFilter(QObject *obj, QEvent *event) override { + if ((obj == view_->horizontalHeader() || obj == view_->horizontalHeader()->viewport()) && event) { + if (event->type() == QEvent::ContextMenu) { + auto *ctx = static_cast(event); + showColumnHeaderContextMenu(ctx->pos()); + return true; + } + } + return QWidget::eventFilter(obj, event); + } + + void showColumnHeaderContextMenu(const QPoint &pos) { + if (!allowColumnHeaderBulkEdit_) return; + const int col = view_->horizontalHeader()->logicalIndexAt(pos); + if (col < 0 || col >= columns_.size()) return; + if (!isColumnBulkEditable(col)) { + showInfo(this, "Bulk update", "This column cannot be edited."); + return; + } + + QMenu menu(this); + const QString colName = columns_.at(col).name; + QAction *applyAll = menu.addAction(QString("Set %1 For All Targets...").arg(colName)); + QAction *chosen = menu.exec(view_->horizontalHeader()->mapToGlobal(pos)); + if (chosen != applyAll) return; + showColumnHeaderBulkEditDialog(col); + } + + void showColumnHeaderBulkEditDialog(int col) { + if (!allowColumnHeaderBulkEdit_) return; + if (!db_ || !db_->isOpen()) { + showWarning(this, "Bulk update", "Not connected"); + return; + } + if (col < 0 || col >= columns_.size()) return; + if (!isColumnBulkEditable(col)) { + showInfo(this, "Bulk update", "This column cannot be edited."); + return; + } + + QString defaultValue; + const QModelIndex current = view_->currentIndex(); + if (current.isValid()) { + QStandardItem *item = model_->item(current.row(), col); + if (item) { + const bool isNull = item->data(Qt::UserRole + 2).toBool(); + const QVariant val = item->data(Qt::UserRole + 1); + defaultValue = displayForVariant(val, isNull); + } + } + + bool ok = false; + const QString column = columns_.at(col).name; + const QString value = QInputDialog::getText( + this, "Bulk update", + QString("Set %1 for all targets:").arg(column), + QLineEdit::Normal, defaultValue, &ok); + if (!ok) return; + + const QStringList obsIds = obsIdsInView(); + if (obsIds.isEmpty()) { + showInfo(this, "Bulk update", "No targets found."); + return; + } + + QStringList errors; + if (!updateColumnForObsIds(obsIds, column, value, &errors)) { + if (!errors.isEmpty()) { + showWarning(this, "Bulk update", errors.join("\n")); + } else { + showWarning(this, "Bulk update", "Update failed."); + } + } + } + + void handleCellClick(const QModelIndex &index, const QPoint &pos) { + if (!groupingEnabled_) return; + if (!index.isValid()) return; + const int nameCol = columnIndex("NAME"); + if (nameCol < 0 || index.column() != nameCol) return; + const int row = index.row(); + if (!isGroupHeaderRow(row)) return; + QRect cellRect = view_->visualRect(index); + const int iconSize = view_->style()->pixelMetric(QStyle::PM_SmallIconSize); + QRect iconRect(cellRect.left() + 4, + cellRect.center().y() - iconSize / 2, + iconSize, iconSize); + if (!iconRect.contains(pos)) return; + const QString key = groupKeyForRow(row); + if (key.isEmpty()) return; + toggleGroup(key); + } + + void moveRowToPositionDialog(int row) { + if (!allowReorder_) return; + if (!searchEdit_->text().trimmed().isEmpty()) { + showInfo(this, "Reorder disabled", "Clear the search filter before reordering."); + return; + } + if (row < 0 || row >= model_->rowCount()) return; + if (!db_ || !db_->isOpen()) { + showWarning(this, "Reorder failed", "Not connected"); + return; + } + + const int obsIdCol = columnIndex("OBSERVATION_ID"); + if (obsIdCol < 0) return; + const QString fromObsId = model_->item(row, obsIdCol)->data(Qt::UserRole + 1).toString(); + if (fromObsId.isEmpty()) return; + + const bool groupMove = groupingEnabled_ && isGroupHeaderRow(row) && + !expandedGroups_.contains(groupKeyForRow(row)); + + int maxPos = 0; + int currentPos = 1; + if (groupMove) { + QVector> order; + order.reserve(groupRowsByKey_.size()); + const int obsOrderCol = columnIndex("OBS_ORDER"); + if (obsIdCol < 0 || obsOrderCol < 0) return; + for (auto it = groupRowsByKey_.begin(); it != groupRowsByKey_.end(); ++it) { + int minOrder = std::numeric_limits::max(); + const int headerRow = groupHeaderRowByKey_.value(it.key(), -1); + for (int memberRow : it.value()) { + QStandardItem *orderItem = model_->item(memberRow, obsOrderCol); + if (!orderItem) continue; + const int orderVal = orderItem->data(Qt::UserRole + 1).toInt(); + if (memberRow == headerRow) { + minOrder = orderVal; + } else if (minOrder == std::numeric_limits::max()) { + minOrder = orderVal; + } else if (headerRow < 0) { + minOrder = std::min(minOrder, orderVal); + } + } + if (minOrder == std::numeric_limits::max()) minOrder = 0; + order.append({it.key(), minOrder}); + } + std::sort(order.begin(), order.end(), + [](const auto &a, const auto &b) { return a.second < b.second; }); + maxPos = order.size(); + const QString fromKey = groupKeyForRow(row); + for (int i = 0; i < order.size(); ++i) { + if (order[i].first == fromKey) { + currentPos = i + 1; + break; + } + } + } else { + const int obsIdCol = columnIndex("OBSERVATION_ID"); + const int obsOrderCol = columnIndex("OBS_ORDER"); + const int setIdCol = columnIndex("SET_ID"); + if (obsIdCol < 0 || obsOrderCol < 0) return; + QList infos; + infos.reserve(model_->rowCount()); + int setId = -1; + if (setIdCol >= 0) { + QStandardItem *setItem = model_->item(row, setIdCol); + if (setItem) setId = setItem->data(Qt::UserRole + 1).toInt(); + } + for (int r = 0; r < model_->rowCount(); ++r) { + QStandardItem *obsItem = model_->item(r, obsIdCol); + QStandardItem *orderItem = model_->item(r, obsOrderCol); + if (!obsItem || !orderItem) continue; + if (setIdCol >= 0 && setId >= 0) { + QStandardItem *setItem = model_->item(r, setIdCol); + if (!setItem || setItem->data(Qt::UserRole + 1).toInt() != setId) continue; + } + RowInfo info; + info.row = r; + info.obsId = obsItem->data(Qt::UserRole + 1).toString(); + info.obsOrder = orderItem->data(Qt::UserRole + 1).toInt(); + infos.append(info); + } + std::sort(infos.begin(), infos.end(), + [](const RowInfo &a, const RowInfo &b) { return a.obsOrder < b.obsOrder; }); + maxPos = infos.size(); + const QString fromObsId = model_->item(row, obsIdCol)->data(Qt::UserRole + 1).toString(); + for (int i = 0; i < infos.size(); ++i) { + if (infos[i].obsId == fromObsId) { + currentPos = i + 1; + break; + } + } + } + + if (maxPos <= 0) return; + if (moveToDialog_) { + moveToDialog_->close(); + moveToDialog_.clear(); + } + + QDialog *dialog = new QDialog(this); + moveToDialog_ = dialog; + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->setWindowModality(Qt::NonModal); + dialog->setModal(false); + dialog->setWindowTitle("Move to Position"); + QVBoxLayout *layout = new QVBoxLayout(dialog); + QFormLayout *form = new QFormLayout(); + QSpinBox *posSpin = new QSpinBox(dialog); + posSpin->setRange(1, maxPos); + posSpin->setValue(currentPos); + form->addRow(QString("Position (1-%1):").arg(maxPos), posSpin); + layout->addLayout(form); + QDialogButtonBox *buttons = + new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, dialog); + connect(buttons, &QDialogButtonBox::accepted, dialog, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, dialog, &QDialog::reject); + layout->addWidget(buttons); + + connect(dialog, &QDialog::accepted, this, [this, fromObsId, groupMove, maxPos, currentPos]() { + if (!moveToDialog_) return; + const QList spins = moveToDialog_->findChildren(); + if (spins.isEmpty()) return; + int newPos = spins.first()->value(); + if (newPos < 1) newPos = 1; + if (newPos > maxPos) newPos = maxPos; + if (newPos == currentPos) return; + + const int fromRow = findRowByColumnValue("OBSERVATION_ID", fromObsId); + if (fromRow < 0) return; + + QString err; + ViewState state = captureViewState(); + bool moved = false; + if (groupMove) { + moved = moveGroupToPositionRow(fromRow, newPos, &err); + } else { + const int setIdCol = columnIndex("SET_ID"); + int setId = -1; + if (setIdCol >= 0) { + QStandardItem *setItem = model_->item(fromRow, setIdCol); + if (setItem) setId = setItem->data(Qt::UserRole + 1).toInt(); + } + moved = moveSingleToPositionRow(fromRow, newPos, setId, &err); + } + if (!moved) { + if (!err.isEmpty()) showWarning(this, "Reorder failed", err); + return; + } + refreshWithState(state); + emit dataMutated(); + }); + connect(dialog, &QDialog::finished, this, [this](int) { moveToDialog_.clear(); }); + dialog->show(); + } + + void showContextMenu(const QPoint &pos) { + const QModelIndex index = view_->indexAt(pos); + if (!index.isValid()) return; + showContextMenuAtRow(index.row(), view_->viewport()->mapToGlobal(pos)); + } + +private: + void showContextMenuAtRow(int row, const QPoint &globalPos) { + if (row < 0 || row >= model_->rowCount()) return; + if (!allowDelete_ && !allowReorder_) return; + const bool searchActive = !searchEdit_->text().trimmed().isEmpty(); + + QMenu menu(this); + QMenu *seqMenu = nullptr; + QAction *deleteAction = nullptr; + QAction *duplicateAction = nullptr; + QAction *moveUp = nullptr; + QAction *moveDown = nullptr; + QAction *moveTop = nullptr; + QAction *moveBottom = nullptr; + QAction *moveTo = nullptr; + QList seqActions; + QAction *ungroupAction = nullptr; + QAction *regroupAction = nullptr; + const int obsIdCol = columnIndex("OBSERVATION_ID"); + const int nameCol = columnIndex("NAME"); + const int obsOrderCol = columnIndex("OBS_ORDER"); + QString obsId; + if (obsIdCol >= 0) { + QStandardItem *obsItem = model_->item(row, obsIdCol); + if (obsItem) obsId = obsItem->data(Qt::UserRole + 1).toString(); + } + const bool canUngroup = groupingEnabled_ && !obsId.isEmpty(); + const bool isUngrouped = canUngroup && manualUngroupObsIds_.contains(obsId); + if (allowReorder_ && !searchActive) { + moveUp = menu.addAction("Move Up"); + moveDown = menu.addAction("Move Down"); + moveTop = menu.addAction("Move to Top"); + moveBottom = menu.addAction("Move to Bottom"); + moveTo = menu.addAction("Move to Position..."); + menu.addSeparator(); + } + if (allowReorder_) { + duplicateAction = menu.addAction("Duplicate"); + } + if (groupingEnabled_ && !obsId.isEmpty()) { + const QString groupKey = groupKeyForRow(row); + const QList members = groupRowsByKey_.value(groupKey); + if (!members.isEmpty()) { + seqMenu = menu.addMenu("Use For Sequencer"); + QList orderedMembers = members; + if (obsOrderCol >= 0) { + std::sort(orderedMembers.begin(), orderedMembers.end(), [&](int a, int b) { + QStandardItem *orderA = model_->item(a, obsOrderCol); + QStandardItem *orderB = model_->item(b, obsOrderCol); + const int oa = orderA ? orderA->data(Qt::UserRole + 1).toInt() : 0; + const int ob = orderB ? orderB->data(Qt::UserRole + 1).toInt() : 0; + return oa < ob; + }); + } + const QString headerObsId = headerObsIdForGroupKey(groupKey); + const QString selectedObsId = selectedObsIdByHeader_.value(headerObsId); + for (int memberRow : orderedMembers) { + const QString memberObsId = obsIdForRow(memberRow); + if (memberObsId.isEmpty()) continue; + QString label = memberObsId; + if (nameCol >= 0) { + QStandardItem *nameItem = model_->item(memberRow, nameCol); + const QString rawName = nameItem ? nameItem->data(Qt::UserRole + 1).toString() : QString(); + if (!rawName.isEmpty()) label = rawName; + } + QAction *act = seqMenu->addAction(label); + act->setData(memberObsId); + act->setCheckable(true); + if (!selectedObsId.isEmpty() && memberObsId == selectedObsId) { + act->setChecked(true); + } + seqActions.append(act); + } + menu.addSeparator(); + } + } + if (canUngroup) { + if (isUngrouped) { + regroupAction = menu.addAction("Restore Grouping"); + } else { + ungroupAction = menu.addAction("Remove From Group"); + } + menu.addSeparator(); + } + if (allowDelete_) { + deleteAction = menu.addAction("Delete"); + } + QAction *chosen = menu.exec(globalPos); + if (!chosen) return; + + if (seqMenu && seqActions.contains(chosen)) { + const QString selectedObsId = chosen->data().toString(); + const QString key = groupKeyForRow(row); + setGroupSequencerSelection(key, selectedObsId); + return; + } + + if (chosen == ungroupAction) { + if (!obsId.isEmpty()) { + const QString key = groupKeyForRow(row); + QList members = groupRowsByKey_.value(key); + const int obsOrderCol = columnIndex("OBS_ORDER"); + int lastRow = row; + int bestOrder = std::numeric_limits::min(); + if (members.size() > 1 && obsOrderCol >= 0) { + for (int memberRow : members) { + if (memberRow == row) continue; + QStandardItem *orderItem = model_->item(memberRow, obsOrderCol); + if (!orderItem) continue; + const int orderVal = orderItem->data(Qt::UserRole + 1).toInt(); + if (orderVal >= bestOrder) { + bestOrder = orderVal; + lastRow = memberRow; + } + } + } + manualUngroupObsIds_.insert(obsId); + manualGroupKeyByObsId_.remove(obsId); + saveGroupingState(); + if (members.size() > 1 && lastRow != row) { + QString err; + if (!moveSingleAfterRowWithRefresh(row, lastRow, &err)) { + if (!err.isEmpty()) showWarning(this, "Reorder failed", err); + applyGrouping(); + } + } else { + applyGrouping(); + } + } + return; + } + if (chosen == regroupAction) { + if (!obsId.isEmpty()) { + manualUngroupObsIds_.remove(obsId); + manualGroupKeyByObsId_.remove(obsId); + saveGroupingState(); + applyGrouping(); + } + return; + } + if (chosen == deleteAction) { + deleteRow(row); + } else if (chosen == duplicateAction) { + duplicateRow(row); + } else if (chosen == moveUp) { + const int target = previousVisibleRow(row); + if (target >= 0) moveRowAfter(row, target, nullptr); + } else if (chosen == moveDown) { + const int target = nextVisibleRow(row); + if (target >= 0) moveRowAfter(row, target, nullptr); + } else if (chosen == moveTop) { + if (!obsId.isEmpty()) { + QString err; + if (!moveGroupToTopObsId(obsId, &err)) { + showWarning(this, "Reorder failed", err.isEmpty() ? "Move to top failed." : err); + } + } else { + const int target = firstVisibleRow(); + if (target >= 0) moveRowAfter(row, target, nullptr); + } + } else if (chosen == moveBottom) { + const int target = lastVisibleRow(); + if (target >= 0) moveRowAfter(row, target, nullptr); + } else if (chosen == moveTo) { + moveRowToPositionDialog(row); + } + } + struct SwapInfo { + bool valid = false; + QVariant obsId; + QVariant obsOrder; + }; + + struct RowInfo { + QString obsId; + int obsOrder = 0; + int row = -1; + int setId = -1; + QString groupKey; + }; + + struct ViewState { + int vScroll = 0; + int hScroll = 0; + QVariantMap keyValues; + int sortColumn = -1; + Qt::SortOrder sortOrder = Qt::AscendingOrder; + }; + + ViewState captureViewState() const { + ViewState state; + state.vScroll = view_->verticalScrollBar()->value(); + state.hScroll = view_->horizontalScrollBar()->value(); + state.keyValues = currentKeyValues(); + if (sortingEnabled_) { + state.sortColumn = view_->horizontalHeader()->sortIndicatorSection(); + state.sortOrder = view_->horizontalHeader()->sortIndicatorOrder(); + } else { + state.sortColumn = -1; + } + return state; + } + + void restoreViewState(const ViewState &state) { + view_->setUpdatesEnabled(false); + if (sortingEnabled_ && state.sortColumn >= 0) { + model_->sort(state.sortColumn, state.sortOrder); + } + if (!state.keyValues.isEmpty()) { + const int row = findRowByKey(state.keyValues); + if (row >= 0) { + const QModelIndex idx = model_->index(row, 0); + view_->selectionModel()->setCurrentIndex( + idx, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + } + } + view_->horizontalScrollBar()->setValue(state.hScroll); + view_->verticalScrollBar()->setValue(state.vScroll); + view_->setUpdatesEnabled(true); + + QTimer::singleShot(0, this, [this, state]() { + if (!view_) return; + view_->horizontalScrollBar()->setValue(state.hScroll); + view_->verticalScrollBar()->setValue(state.vScroll); + }); + } + + void refreshWithState(const ViewState &state) { + if (!db_ || !db_->isOpen() || tableName_.isEmpty()) { + statusLabel_->setText("Not connected"); + return; + } + QString error; + if (!db_->loadColumns(tableName_, columns_, &error)) { + statusLabel_->setText(error.isEmpty() ? "Failed to read columns" : error); + return; + } + QVector> rows; + const QString searchValue = searchEdit_->text().trimmed(); + if (!db_->fetchRows(tableName_, columns_, + fixedFilterColumn_, fixedFilterValue_, + searchColumn_, searchValue, + orderByColumn_, + rows, &error)) { + statusLabel_->setText(error.isEmpty() ? "Failed to read rows" : error); + return; + } + + suppressItemChange_ = true; + model_->clear(); + model_->setColumnCount(columns_.size()); + QStringList headers; + for (const ColumnMeta &meta : columns_) { + headers << meta.name; + } + model_->setHorizontalHeaderLabels(headers); + if (headerSaveTimer_ && headerSaveTimer_->isActive()) { + headerSaveTimer_->stop(); + saveHeaderState(); + } + const bool restoredHeader = restoreHeaderState(); + if (!restoredHeader) { + applyColumnOrderRules(); + } else if (headerRulesPending_) { + applyColumnOrderRules(); + headerRulesPending_ = false; + saveHeaderState(); + } + applyHiddenColumns(); + + const QColor textColor = view_->palette().color(QPalette::Text); + const QColor nullColor = view_->palette().color(QPalette::Disabled, QPalette::Text); + + for (const QVector &rowValues : rows) { + QList items; + items.reserve(columns_.size()); + for (int col = 0; col < columns_.size(); ++col) { + QVariant value; + if (col < rowValues.size()) { + value = rowValues.at(col); + } + const bool isNull = !value.isValid() || value.isNull(); + QStandardItem *item = new QStandardItem(displayForVariant(value, isNull)); + const QString colName = columns_.at(col).name.toUpper(); + if (colName == "EXPTIME") { + item->setToolTip("Format: SET or SNR . Example: SET 600"); + } else if (colName == "SLITWIDTH") { + item->setToolTip("Format: SET , SNR , LOSS , RES , AUTO"); + } else if (colName == "SLITANGLE") { + item->setToolTip("Format: numeric degrees or PA"); + } else if (colName == "MAGFILTER") { + item->setToolTip("U,B,V,R,I,J,K, or match. G is mapped to match."); + } else if (colName == "CHANNEL") { + item->setToolTip("U, G, R, or I"); + } else if (colName == "WRANGE_LOW" || colName == "WRANGE_HIGH") { + item->setToolTip("Wavelength range in nm; defaults around channel center"); + } + item->setEditable(true); + if (!isNull) { + item->setData(value, Qt::EditRole); + } else { + item->setData(QVariant(), Qt::EditRole); + } + item->setData(isNull ? QVariant() : value, Qt::UserRole + 1); + item->setData(isNull, Qt::UserRole + 2); + item->setForeground(QBrush(isNull ? nullColor : textColor)); + items.push_back(item); + } + model_->appendRow(items); + } + + suppressItemChange_ = false; + restoreViewState(state); + if (!restoredHeader) { + saveHeaderState(); + } + const QString timestamp = QDateTime::currentDateTime().toString("yyyy-MM-dd HH:mm:ss"); + statusLabel_->setText(QString("Last refresh: %1").arg(timestamp)); + + if (!groupingStateLoaded_) { + loadGroupingState(); + groupingStateLoaded_ = true; + } + applyGrouping(); + } + + int findRowByKey(const QVariantMap &keyValues) const { + if (keyValues.isEmpty()) return -1; + for (int row = 0; row < model_->rowCount(); ++row) { + bool match = true; + for (int col = 0; col < columns_.size(); ++col) { + if (!columns_[col].isPrimaryKey()) continue; + QStandardItem *item = model_->item(row, col); + if (!item) { match = false; break; } + QVariant val = item->data(Qt::UserRole + 1); + if (val.toString() != keyValues.value(columns_[col].name).toString()) { + match = false; + break; + } + } + if (match) return row; + } + return -1; + } + + int findRowByColumnValue(const QString &columnName, const QVariant &value) const { + const int col = columnIndex(columnName); + if (col < 0) return -1; + for (int row = 0; row < model_->rowCount(); ++row) { + QStandardItem *item = model_->item(row, col); + if (!item) continue; + const QVariant cellValue = item->data(Qt::UserRole + 1); + if (cellValue.toString() == value.toString()) { + return row; + } + } + return -1; + } + + SwapInfo swapInfoForRow(int row) const { + SwapInfo info; + if (row < 0 || row >= model_->rowCount()) return info; + const int obsIdCol = columnIndex("OBSERVATION_ID"); + const int obsOrderCol = columnIndex("OBS_ORDER"); + if (obsIdCol < 0 || obsOrderCol < 0) return info; + QStandardItem *obsItem = model_->item(row, obsIdCol); + QStandardItem *orderItem = model_->item(row, obsOrderCol); + if (!obsItem || !orderItem) return info; + QVariant obsId = obsItem->data(Qt::UserRole + 1); + QVariant obsOrder = orderItem->data(Qt::UserRole + 1); + if (!obsId.isValid() || !obsOrder.isValid()) return info; + info.valid = true; + info.obsId = obsId; + info.obsOrder = obsOrder; + return info; + } + + bool isGroupHeaderRow(int row) const { + if (!groupingEnabled_) return false; + if (!groupKeyByRow_.contains(row)) return false; + const QString key = groupKeyByRow_.value(row); + const int headerRow = groupHeaderRowByKey_.value(key, -1); + return headerRow == row; + } + + QString groupKeyForRow(int row) const { + return groupKeyByRow_.value(row); + } + + QString obsIdForRow(int row) const { + const int obsIdCol = columnIndex("OBSERVATION_ID"); + if (obsIdCol < 0 || row < 0 || row >= model_->rowCount()) return QString(); + QStandardItem *item = model_->item(row, obsIdCol); + if (!item) return QString(); + return item->data(Qt::UserRole + 1).toString(); + } + + QString headerObsIdForGroupKey(const QString &groupKey) const { + if (groupKey.isEmpty()) return QString(); + const int headerRow = groupHeaderRowByKey_.value(groupKey, -1); + QString headerObsId = obsIdForRow(headerRow); + if (!headerObsId.isEmpty()) return headerObsId; + const QList members = groupRowsByKey_.value(groupKey); + if (!members.isEmpty()) { + headerObsId = obsIdForRow(members.first()); + } + return headerObsId; + } + + void setGroupSequencerSelection(const QString &groupKey, const QString &selectedObsId) { + if (groupKey.isEmpty() || selectedObsId.isEmpty()) return; + const QList members = groupRowsByKey_.value(groupKey); + bool inGroup = false; + for (int row : members) { + if (obsIdForRow(row) == selectedObsId) { + inGroup = true; + break; + } + } + if (!inGroup) return; + const QString headerObsId = headerObsIdForGroupKey(groupKey); + if (headerObsId.isEmpty()) return; + selectedObsIdByHeader_[headerObsId] = selectedObsId; + saveGroupingState(); + applyGrouping(); + } + + void toggleGroup(const QString &key) { + if (key.isEmpty()) return; + if (expandedGroups_.contains(key)) { + expandedGroups_.remove(key); + } else { + expandedGroups_.insert(key); + } + applyGrouping(); + } + + void applyGrouping() { + if (!groupingEnabled_) { + for (int row = 0; row < model_->rowCount(); ++row) { + view_->setRowHidden(row, false); + } + return; + } + + const bool prevSuppress = suppressItemChange_; + suppressItemChange_ = true; + + const int iconSize = view_->style()->pixelMetric(QStyle::PM_SmallIconSize); + const QColor iconColor(90, 160, 255); + auto makeArrowIcon = [&](Qt::ArrowType arrow) { + QPixmap pix(iconSize, iconSize); + pix.fill(Qt::transparent); + QPainter p(&pix); + p.setRenderHint(QPainter::Antialiasing, true); + p.setPen(Qt::NoPen); + p.setBrush(iconColor); + QPolygon poly; + if (arrow == Qt::DownArrow) { + poly << QPoint(iconSize / 2, iconSize - 2) + << QPoint(2, 2) + << QPoint(iconSize - 2, 2); + } else { + poly << QPoint(iconSize - 2, iconSize / 2) + << QPoint(2, 2) + << QPoint(2, iconSize - 2); + } + p.drawPolygon(poly); + return QIcon(pix); + }; + QIcon collapsedIcon = makeArrowIcon(Qt::RightArrow); + QIcon expandedIcon = makeArrowIcon(Qt::DownArrow); + + const int obsIdCol = columnIndex("OBSERVATION_ID"); + const int nameCol = columnIndex("NAME"); + if (obsIdCol < 0 || nameCol < 0) return; + + const int raCol = columnIndex("RA"); + const int decCol = columnIndex("DECL"); + const int offsetRaCol = columnIndex("OFFSET_RA"); + const int offsetDecCol = columnIndex("OFFSET_DEC"); + const int draCol = columnIndex("DRA"); + const int ddecCol = columnIndex("DDEC"); + + struct RowInfo { + int row = -1; + QString obsId; + QString name; + QString groupKey; + bool isScience = false; + int obsOrder = 0; + bool coordOk = false; + double raDeg = 0.0; + double decDeg = 0.0; + }; + + auto assignGroupKeys = [&](QVector &rows) { + struct GroupCenter { + QString key; + double ra = 0.0; + double dec = 0.0; + }; + QVector centers; + auto makeKey = [](double raDeg, double decDeg) { + return QString("%1:%2") + .arg(QString::number(raDeg, 'f', 6)) + .arg(QString::number(decDeg, 'f', 6)); + }; + for (const RowInfo &info : rows) { + if (manualUngroupObsIds_.contains(info.obsId)) { + continue; + } + if (info.coordOk && info.isScience) { + centers.push_back({makeKey(info.raDeg, info.decDeg), info.raDeg, info.decDeg}); + } + } + const bool hasScienceCenters = !centers.isEmpty(); + for (RowInfo &info : rows) { + if (manualUngroupObsIds_.contains(info.obsId)) { + info.groupKey = QString("UNGROUP:%1").arg(info.obsId); + continue; + } + const QString manualKey = manualGroupKeyByObsId_.value(info.obsId); + if (!manualKey.isEmpty()) { + info.groupKey = manualKey; + continue; + } + if (!info.coordOk) { + info.groupKey = QString("OBS:%1").arg(info.obsId); + continue; + } + if (!hasScienceCenters) { + info.groupKey = QString("OBS:%1").arg(info.obsId); + continue; + } + double bestSep = 1e12; + int bestIdx = -1; + for (int i = 0; i < centers.size(); ++i) { + const double sep = angularSeparationArcsec(info.raDeg, info.decDeg, + centers[i].ra, centers[i].dec); + if (sep < bestSep) { + bestSep = sep; + bestIdx = i; + } + } + if (bestIdx >= 0 && bestSep <= kGroupCoordTolArcsec) { + info.groupKey = centers[bestIdx].key; + } else { + info.groupKey = QString("OBS:%1").arg(info.obsId); + } + } + }; + + auto collectRows = [&]() { + QVector rows; + rows.reserve(model_->rowCount()); + for (int row = 0; row < model_->rowCount(); ++row) { + QStandardItem *obsItem = model_->item(row, obsIdCol); + QStandardItem *nameItem = model_->item(row, nameCol); + if (!obsItem || !nameItem) continue; + const QString obsId = obsItem->data(Qt::UserRole + 1).toString(); + const QString name = nameItem->data(Qt::UserRole + 1).toString(); + QVariantMap values; + if (raCol >= 0) values.insert("RA", model_->item(row, raCol)->data(Qt::UserRole + 1)); + if (decCol >= 0) values.insert("DECL", model_->item(row, decCol)->data(Qt::UserRole + 1)); + if (offsetRaCol >= 0) values.insert("OFFSET_RA", model_->item(row, offsetRaCol)->data(Qt::UserRole + 1)); + if (offsetDecCol >= 0) values.insert("OFFSET_DEC", model_->item(row, offsetDecCol)->data(Qt::UserRole + 1)); + if (draCol >= 0) values.insert("DRA", model_->item(row, draCol)->data(Qt::UserRole + 1)); + if (ddecCol >= 0) values.insert("DDEC", model_->item(row, ddecCol)->data(Qt::UserRole + 1)); + + bool hasRa = false; + bool hasDec = false; + const double offsetRa = offsetArcsecFromValues(values, {"OFFSET_RA", "DRA"}, &hasRa); + const double offsetDec = offsetArcsecFromValues(values, {"OFFSET_DEC", "DDEC"}, &hasDec); + const bool isScience = (!hasRa && !hasDec) || + (std::abs(offsetRa) <= kOffsetZeroTolArcsec && + std::abs(offsetDec) <= kOffsetZeroTolArcsec); + + int obsOrder = 0; + const int obsOrderCol = columnIndex("OBS_ORDER"); + if (obsOrderCol >= 0) { + QStandardItem *orderItem = model_->item(row, obsOrderCol); + if (orderItem) obsOrder = orderItem->data(Qt::UserRole + 1).toInt(); + } + + double raDeg = 0.0; + double decDeg = 0.0; + const bool coordOk = computeScienceCoordDegreesProjected(values, &raDeg, &decDeg); + RowInfo info; + info.row = row; + info.obsId = obsId; + info.name = name; + info.groupKey = QString(); + info.isScience = isScience; + info.obsOrder = obsOrder; + info.coordOk = coordOk; + info.raDeg = raDeg; + info.decDeg = decDeg; + rows.push_back(info); + } + assignGroupKeys(rows); + return rows; + }; + + auto buildMaps = [&](const QVector &rows, + QHash> &rowsByKey, + QHash &headerByKey, + QHash &keyByRow) { + rowsByKey.clear(); + headerByKey.clear(); + keyByRow.clear(); + for (const RowInfo &info : rows) { + rowsByKey[info.groupKey].append(info.row); + if (info.isScience && !headerByKey.contains(info.groupKey)) { + headerByKey[info.groupKey] = info.row; + } + keyByRow.insert(info.row, info.groupKey); + } + for (auto it = rowsByKey.begin(); it != rowsByKey.end(); ++it) { + if (!headerByKey.contains(it.key()) && !it.value().isEmpty()) { + headerByKey[it.key()] = it.value().first(); + } + } + }; + + QVector rows = collectRows(); + QHash> rowsByKey; + QHash headerByKey; + QHash keyByRow; + buildMaps(rows, rowsByKey, headerByKey, keyByRow); + + QVector currentOrder; + currentOrder.reserve(model_->rowCount()); + for (int row = 0; row < model_->rowCount(); ++row) { + QStandardItem *obsItem = model_->item(row, obsIdCol); + if (!obsItem) continue; + currentOrder << obsItem->data(Qt::UserRole + 1).toString(); + } + + struct GroupOrder { + QString key; + int headerOrder = 0; + QVector members; + QString headerObsId; + }; + + QHash> rowsByGroup; + for (const RowInfo &info : rows) { + rowsByGroup[info.groupKey].append(info); + } + + QVector groupOrder; + groupOrder.reserve(rowsByGroup.size()); + for (auto it = rowsByGroup.begin(); it != rowsByGroup.end(); ++it) { + QVector members = it.value(); + std::sort(members.begin(), members.end(), + [](const RowInfo &a, const RowInfo &b) { return a.obsOrder < b.obsOrder; }); + QString headerObsId; + int headerOrder = members.isEmpty() ? 0 : members.first().obsOrder; + for (const RowInfo &info : members) { + if (info.isScience) { + headerObsId = info.obsId; + headerOrder = info.obsOrder; + break; + } + } + if (headerObsId.isEmpty() && !members.isEmpty()) { + headerObsId = members.first().obsId; + } + groupOrder.push_back({it.key(), headerOrder, members, headerObsId}); + } + + std::sort(groupOrder.begin(), groupOrder.end(), + [](const GroupOrder &a, const GroupOrder &b) { return a.headerOrder < b.headerOrder; }); + + QVector desiredOrder; + for (const GroupOrder &group : groupOrder) { + if (!group.headerObsId.isEmpty()) { + desiredOrder.append(group.headerObsId); + } + for (const RowInfo &member : group.members) { + if (member.obsId == group.headerObsId) continue; + desiredOrder.append(member.obsId); + } + } + + if (desiredOrder.size() == currentOrder.size() && desiredOrder != currentOrder) { + QVariantMap selectedKeys = currentKeyValues(); + const int vScroll = view_->verticalScrollBar()->value(); + const int hScroll = view_->horizontalScrollBar()->value(); + + QHash obsIdByRow; + obsIdByRow.reserve(model_->rowCount()); + for (int row = 0; row < model_->rowCount(); ++row) { + obsIdByRow.insert(row, currentOrder.value(row)); + } + QHash> itemsByObsId; + for (int row = model_->rowCount() - 1; row >= 0; --row) { + const QString obsId = obsIdByRow.value(row); + itemsByObsId.insert(obsId, model_->takeRow(row)); + } + for (const QString &obsId : desiredOrder) { + if (!itemsByObsId.contains(obsId)) continue; + model_->insertRow(model_->rowCount(), itemsByObsId.take(obsId)); + } + for (auto it = itemsByObsId.begin(); it != itemsByObsId.end(); ++it) { + model_->insertRow(model_->rowCount(), it.value()); + } + + if (!selectedKeys.isEmpty()) { + const int selRow = findRowByKey(selectedKeys); + if (selRow >= 0) { + const QModelIndex idx = model_->index(selRow, 0); + view_->selectionModel()->setCurrentIndex( + idx, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + } + } + view_->verticalScrollBar()->setValue(vScroll); + view_->horizontalScrollBar()->setValue(hScroll); + } + + rows = collectRows(); + buildMaps(rows, groupRowsByKey_, groupHeaderRowByKey_, groupKeyByRow_); + + rowsByGroup.clear(); + rowsByGroup.reserve(groupRowsByKey_.size()); + for (const RowInfo &info : rows) { + rowsByGroup[info.groupKey].append(info); + } + + QHash selectedByKey; + QSet validHeaderObsIds; + bool selectionChanged = false; + for (auto it = rowsByGroup.begin(); it != rowsByGroup.end(); ++it) { + QVector members = it.value(); + if (members.isEmpty()) continue; + std::sort(members.begin(), members.end(), + [](const RowInfo &a, const RowInfo &b) { return a.obsOrder < b.obsOrder; }); + + QString headerObsId; + const int headerRow = groupHeaderRowByKey_.value(it.key(), -1); + if (headerRow >= 0) { + QStandardItem *headerItem = model_->item(headerRow, obsIdCol); + if (headerItem) headerObsId = headerItem->data(Qt::UserRole + 1).toString(); + } + if (headerObsId.isEmpty()) { + headerObsId = members.first().obsId; + } + if (headerObsId.isEmpty()) continue; + validHeaderObsIds.insert(headerObsId); + + QString selectedObsId = selectedObsIdByHeader_.value(headerObsId); + bool selectedValid = false; + for (const RowInfo &member : members) { + if (member.obsId == selectedObsId) { + selectedValid = true; + break; + } + } + if (!selectedValid) { + QString defaultObsId; + for (const RowInfo &member : members) { + if (!member.isScience) { + defaultObsId = member.obsId; + break; + } + } + if (defaultObsId.isEmpty()) defaultObsId = headerObsId; + selectedObsId = defaultObsId; + selectedObsIdByHeader_[headerObsId] = selectedObsId; + selectionChanged = true; + } + selectedByKey.insert(it.key(), selectedObsId); + } + + for (auto it = selectedObsIdByHeader_.begin(); it != selectedObsIdByHeader_.end(); ) { + if (!validHeaderObsIds.contains(it.key())) { + it = selectedObsIdByHeader_.erase(it); + selectionChanged = true; + } else { + ++it; + } + } + if (selectionChanged) { + saveGroupingState(); + } + + const int stateCol = columnIndex("STATE"); + if (stateCol >= 0 && db_ && db_->isOpen() && !rowsByGroup.isEmpty()) { + QList keyValuesList; + QList stateValues; + struct StateUpdate { + int row = -1; + QString value; + }; + QVector stateUpdates; + + auto shouldOverrideState = [](const QString &state) { + const QString s = state.trimmed().toLower(); + return s.isEmpty() || s == "pending" || s == "unassigned"; + }; + + for (auto it = rowsByGroup.begin(); it != rowsByGroup.end(); ++it) { + const QString selectedObsId = selectedByKey.value(it.key()); + if (selectedObsId.isEmpty()) continue; + for (const RowInfo &member : it.value()) { + QStandardItem *stateItem = model_->item(member.row, stateCol); + if (!stateItem) continue; + const QString currentState = stateItem->data(Qt::UserRole + 1).toString(); + if (!shouldOverrideState(currentState)) continue; + const bool isSelected = (member.obsId == selectedObsId); + const QString desiredState = isSelected ? kDefaultTargetState : QString("unassigned"); + if (currentState.compare(desiredState, Qt::CaseInsensitive) == 0) continue; + + QVariantMap keyValues = keyValuesForRow(member.row); + if (keyValues.isEmpty()) continue; + keyValuesList.append(keyValues); + stateValues.append(desiredState); + stateUpdates.push_back({member.row, desiredState}); + } + } + + if (!keyValuesList.isEmpty()) { + QString err; + if (db_->updateColumnByKeyBatch(tableName_, "STATE", keyValuesList, stateValues, &err)) { + const bool prev = suppressItemChange_; + suppressItemChange_ = true; + for (const StateUpdate &upd : stateUpdates) { + if (upd.row < 0) continue; + QStandardItem *item = model_->item(upd.row, stateCol); + if (!item) continue; + item->setData(upd.value, Qt::EditRole); + item->setData(upd.value, Qt::UserRole + 1); + item->setData(false, Qt::UserRole + 2); + item->setText(displayForVariant(upd.value, false)); + item->setForeground(QBrush(view_->palette().color(QPalette::Text))); + } + suppressItemChange_ = prev; + } else { + qWarning().noquote() << QString("WARN: failed to update STATE selection: %1").arg(err); + } + } + } + + QHash groupAnyPending; + if (stateCol >= 0) { + groupAnyPending.reserve(groupRowsByKey_.size()); + for (auto it = groupRowsByKey_.begin(); it != groupRowsByKey_.end(); ++it) { + bool anyPending = false; + for (int memberRow : it.value()) { + QStandardItem *stateItem = model_->item(memberRow, stateCol); + if (!stateItem) continue; + const QString state = stateItem->data(Qt::UserRole + 1).toString().trimmed().toLower(); + if (state == "pending") { + anyPending = true; + break; + } + } + groupAnyPending.insert(it.key(), anyPending); + } + } + + for (const RowInfo &info : rows) { + const QString key = info.groupKey; + const bool isHeader = (groupHeaderRowByKey_.value(key, -1) == info.row); + const bool expanded = expandedGroups_.contains(key); + const int count = groupRowsByKey_.value(key).size(); + view_->setRowHidden(info.row, !(isHeader || expanded)); + + QStandardItem *nameItem = model_->item(info.row, nameCol); + if (!nameItem) continue; + const QString rawName = nameItem->data(Qt::UserRole + 1).toString(); + QString baseName = rawName; + QRegularExpression countSuffixRe("\\s*\\((\\d+)\\)\\s*$"); + QRegularExpressionMatch countMatch = countSuffixRe.match(baseName); + if (countMatch.hasMatch()) { + bool okCount = false; + const int suffixCount = countMatch.captured(1).toInt(&okCount); + if (okCount && (suffixCount == count || suffixCount == 1)) { + baseName = baseName.left(countMatch.capturedStart()).trimmed(); + } + } + QString displayName = baseName; + if (isHeader) { + if (count > 1) { + nameItem->setData(expanded ? expandedIcon : collapsedIcon, Qt::DecorationRole); + } else { + nameItem->setData(QVariant(), Qt::DecorationRole); + } + } else { + nameItem->setData(QVariant(), Qt::DecorationRole); + if (expanded) { + displayName = QString(" %1").arg(baseName); + } + } + const QString selectedObsId = selectedByKey.value(key); + const bool isSelected = (!selectedObsId.isEmpty() && info.obsId == selectedObsId); + QFont nameFont = nameItem->font(); + nameFont.setBold(isSelected); + nameItem->setFont(nameFont); + nameItem->setData(displayName, Qt::DisplayRole); + + if (stateCol >= 0) { + QStandardItem *stateItem = model_->item(info.row, stateCol); + if (stateItem) { + const bool collapsed = isHeader && !expanded; + if (collapsed && groupAnyPending.value(key, false)) { + stateItem->setData("pending", Qt::DisplayRole); + } else { + const QVariant val = stateItem->data(Qt::UserRole + 1); + const bool isNull = stateItem->data(Qt::UserRole + 2).toBool(); + stateItem->setData(displayForVariant(val, isNull), Qt::DisplayRole); + } + } + } + } + + suppressItemChange_ = prevSuppress; + updateGroupSequenceNumbers(); + } + + bool moveSingleAfterRow(int fromRow, int toRow, int setId, QString *error) { + const int obsIdCol = columnIndex("OBSERVATION_ID"); + const int obsOrderCol = columnIndex("OBS_ORDER"); + const int setIdCol = columnIndex("SET_ID"); + if (obsIdCol < 0 || obsOrderCol < 0) { + if (error) *error = "OBSERVATION_ID/OBS_ORDER columns missing."; + return false; + } + + QList infos; + infos.reserve(model_->rowCount()); + for (int row = 0; row < model_->rowCount(); ++row) { + QStandardItem *obsItem = model_->item(row, obsIdCol); + QStandardItem *orderItem = model_->item(row, obsOrderCol); + if (!obsItem || !orderItem) continue; + if (setIdCol >= 0 && setId >= 0) { + QStandardItem *setItem = model_->item(row, setIdCol); + if (!setItem || setItem->data(Qt::UserRole + 1).toInt() != setId) continue; + } + RowInfo info; + info.row = row; + info.obsId = obsItem->data(Qt::UserRole + 1).toString(); + info.obsOrder = orderItem->data(Qt::UserRole + 1).toInt(); + infos.append(info); + } + std::sort(infos.begin(), infos.end(), + [](const RowInfo &a, const RowInfo &b) { return a.obsOrder < b.obsOrder; }); + + const QString fromObsId = model_->item(fromRow, obsIdCol)->data(Qt::UserRole + 1).toString(); + const QString toObsId = model_->item(toRow, obsIdCol)->data(Qt::UserRole + 1).toString(); + int fromIdx = -1; + int toIdx = -1; + for (int i = 0; i < infos.size(); ++i) { + if (infos[i].obsId == fromObsId) fromIdx = i; + if (infos[i].obsId == toObsId) toIdx = i; + } + if (fromIdx < 0 || toIdx < 0) { + if (error) *error = "Target not found for reorder."; + return false; + } + if (fromIdx == toIdx) return true; + + RowInfo moving = infos.takeAt(fromIdx); + if (fromIdx < toIdx) toIdx--; + const int insertIdx = std::min(toIdx + 1, static_cast(infos.size())); + infos.insert(insertIdx, moving); + + QVector obsIds; + QVector orderValues; + obsIds.reserve(infos.size()); + orderValues.reserve(infos.size()); + for (int i = 0; i < infos.size(); ++i) { + obsIds << infos[i].obsId; + orderValues << (i + 1); + } + + QString err; + if (!db_->updateObsOrderByObservationId(tableName_, obsIds, orderValues, &err)) { + if (error) *error = err; + return false; + } + return true; + } + + bool moveSingleToPositionRow(int fromRow, int position, int setId, QString *error) { + const int obsIdCol = columnIndex("OBSERVATION_ID"); + const int obsOrderCol = columnIndex("OBS_ORDER"); + const int setIdCol = columnIndex("SET_ID"); + if (obsIdCol < 0 || obsOrderCol < 0) { + if (error) *error = "OBSERVATION_ID/OBS_ORDER columns missing."; + return false; + } + + QList infos; + infos.reserve(model_->rowCount()); + for (int row = 0; row < model_->rowCount(); ++row) { + QStandardItem *obsItem = model_->item(row, obsIdCol); + QStandardItem *orderItem = model_->item(row, obsOrderCol); + if (!obsItem || !orderItem) continue; + if (setIdCol >= 0 && setId >= 0) { + QStandardItem *setItem = model_->item(row, setIdCol); + if (!setItem || setItem->data(Qt::UserRole + 1).toInt() != setId) continue; + } + RowInfo info; + info.row = row; + info.obsId = obsItem->data(Qt::UserRole + 1).toString(); + info.obsOrder = orderItem->data(Qt::UserRole + 1).toInt(); + infos.append(info); + } + std::sort(infos.begin(), infos.end(), + [](const RowInfo &a, const RowInfo &b) { return a.obsOrder < b.obsOrder; }); + + const QString fromObsId = model_->item(fromRow, obsIdCol)->data(Qt::UserRole + 1).toString(); + int fromIdx = -1; + for (int i = 0; i < infos.size(); ++i) { + if (infos[i].obsId == fromObsId) { + fromIdx = i; + break; + } + } + if (fromIdx < 0) { + if (error) *error = "Target not found for reorder."; + return false; + } + if (infos.isEmpty()) return true; + const int targetIdx = std::clamp(position - 1, 0, static_cast(infos.size() - 1)); + if (fromIdx == targetIdx) return true; + + RowInfo moving = infos.takeAt(fromIdx); + int insertIdx = targetIdx; + if (fromIdx < targetIdx) insertIdx--; + if (insertIdx < 0) insertIdx = 0; + if (insertIdx > infos.size()) insertIdx = infos.size(); + infos.insert(insertIdx, moving); + + QVector obsIds; + QVector orderValues; + obsIds.reserve(infos.size()); + orderValues.reserve(infos.size()); + for (int i = 0; i < infos.size(); ++i) { + obsIds << infos[i].obsId; + orderValues << (i + 1); + } + + QString err; + if (!db_->updateObsOrderByObservationId(tableName_, obsIds, orderValues, &err)) { + if (error) *error = err; + return false; + } + return true; + } + + bool moveSingleToTopRow(int fromRow, int setId, QString *error) { + const int obsIdCol = columnIndex("OBSERVATION_ID"); + const int obsOrderCol = columnIndex("OBS_ORDER"); + const int setIdCol = columnIndex("SET_ID"); + if (obsIdCol < 0 || obsOrderCol < 0) { + if (error) *error = "OBSERVATION_ID/OBS_ORDER columns missing."; + return false; + } + + QList infos; + infos.reserve(model_->rowCount()); + for (int row = 0; row < model_->rowCount(); ++row) { + QStandardItem *obsItem = model_->item(row, obsIdCol); + QStandardItem *orderItem = model_->item(row, obsOrderCol); + if (!obsItem || !orderItem) continue; + if (setIdCol >= 0 && setId >= 0) { + QStandardItem *setItem = model_->item(row, setIdCol); + if (!setItem || setItem->data(Qt::UserRole + 1).toInt() != setId) continue; + } + RowInfo info; + info.row = row; + info.obsId = obsItem->data(Qt::UserRole + 1).toString(); + info.obsOrder = orderItem->data(Qt::UserRole + 1).toInt(); + infos.append(info); + } + std::sort(infos.begin(), infos.end(), + [](const RowInfo &a, const RowInfo &b) { return a.obsOrder < b.obsOrder; }); + + const QString fromObsId = model_->item(fromRow, obsIdCol)->data(Qt::UserRole + 1).toString(); + int fromIdx = -1; + for (int i = 0; i < infos.size(); ++i) { + if (infos[i].obsId == fromObsId) { + fromIdx = i; + break; + } + } + if (fromIdx < 0) { + if (error) *error = "Target not found for reorder."; + return false; + } + if (fromIdx == 0) return true; + + RowInfo moving = infos.takeAt(fromIdx); + infos.insert(0, moving); + + QVector obsIds; + QVector orderValues; + obsIds.reserve(infos.size()); + orderValues.reserve(infos.size()); + for (int i = 0; i < infos.size(); ++i) { + obsIds << infos[i].obsId; + orderValues << (i + 1); + } + + QString err; + if (!db_->updateObsOrderByObservationId(tableName_, obsIds, orderValues, &err)) { + if (error) *error = err; + return false; + } + return true; + } + + bool moveGroupAfterRow(int fromRow, int toRow, QString *error) { + const QString fromKey = groupKeyForRow(fromRow); + const QString toKey = groupKeyForRow(toRow); + if (fromKey.isEmpty() || toKey.isEmpty()) { + if (error) *error = "Group not found."; + return false; + } + if (fromKey == toKey) return true; + + const int obsIdCol = columnIndex("OBSERVATION_ID"); + const int obsOrderCol = columnIndex("OBS_ORDER"); + if (obsIdCol < 0 || obsOrderCol < 0) { + if (error) *error = "OBSERVATION_ID/OBS_ORDER columns missing."; + return false; + } + + struct GroupBlock { + QString key; + int minOrder = 0; + QVector members; + }; + + QHash blocks; + for (auto it = groupRowsByKey_.begin(); it != groupRowsByKey_.end(); ++it) { + GroupBlock block; + block.key = it.key(); + block.minOrder = std::numeric_limits::max(); + const int headerRow = groupHeaderRowByKey_.value(block.key, -1); + for (int row : it.value()) { + QStandardItem *obsItem = model_->item(row, obsIdCol); + QStandardItem *orderItem = model_->item(row, obsOrderCol); + if (!obsItem || !orderItem) continue; + RowInfo info; + info.row = row; + info.obsId = obsItem->data(Qt::UserRole + 1).toString(); + info.obsOrder = orderItem->data(Qt::UserRole + 1).toInt(); + block.members.append(info); + if (row == headerRow) { + block.minOrder = info.obsOrder; + } else if (block.minOrder == std::numeric_limits::max()) { + block.minOrder = info.obsOrder; + } else if (headerRow < 0) { + block.minOrder = std::min(block.minOrder, info.obsOrder); + } + } + std::sort(block.members.begin(), block.members.end(), + [](const RowInfo &a, const RowInfo &b) { return a.obsOrder < b.obsOrder; }); + blocks.insert(block.key, block); + } + + QVector order; + order.reserve(blocks.size()); + for (const GroupBlock &block : blocks) { + order.push_back(block); + } + std::sort(order.begin(), order.end(), + [](const GroupBlock &a, const GroupBlock &b) { return a.minOrder < b.minOrder; }); + + int fromIdx = -1; + int toIdx = -1; + for (int i = 0; i < order.size(); ++i) { + if (order[i].key == fromKey) fromIdx = i; + if (order[i].key == toKey) toIdx = i; + } + if (fromIdx < 0 || toIdx < 0) { + if (error) *error = "Group not found for reorder."; + return false; + } + if (fromIdx == toIdx) return true; + + GroupBlock moving = order.takeAt(fromIdx); + if (fromIdx < toIdx) toIdx--; + const int insertIdx = std::min(toIdx + 1, static_cast(order.size())); + order.insert(insertIdx, moving); + + QVector obsIds; + QVector orderValues; + int counter = 1; + for (const GroupBlock &block : order) { + for (const RowInfo &member : block.members) { + obsIds << member.obsId; + orderValues << counter++; + } + } + + QString err; + if (!db_->updateObsOrderByObservationId(tableName_, obsIds, orderValues, &err)) { + if (error) *error = err; + return false; + } + return true; + } + + bool moveGroupToPositionRow(int fromRow, int position, QString *error) { + const QString fromKey = groupKeyForRow(fromRow); + if (fromKey.isEmpty()) { + if (error) *error = "Group not found."; + return false; + } + + const int obsIdCol = columnIndex("OBSERVATION_ID"); + const int obsOrderCol = columnIndex("OBS_ORDER"); + if (obsIdCol < 0 || obsOrderCol < 0) { + if (error) *error = "OBSERVATION_ID/OBS_ORDER columns missing."; + return false; + } + + struct GroupBlock { + QString key; + int minOrder = 0; + QVector members; + }; + + QHash blocks; + for (auto it = groupRowsByKey_.begin(); it != groupRowsByKey_.end(); ++it) { + GroupBlock block; + block.key = it.key(); + block.minOrder = std::numeric_limits::max(); + const int headerRow = groupHeaderRowByKey_.value(block.key, -1); + for (int row : it.value()) { + QStandardItem *obsItem = model_->item(row, obsIdCol); + QStandardItem *orderItem = model_->item(row, obsOrderCol); + if (!obsItem || !orderItem) continue; + RowInfo info; + info.row = row; + info.obsId = obsItem->data(Qt::UserRole + 1).toString(); + info.obsOrder = orderItem->data(Qt::UserRole + 1).toInt(); + block.members.append(info); + if (row == headerRow) { + block.minOrder = info.obsOrder; + } else if (block.minOrder == std::numeric_limits::max()) { + block.minOrder = info.obsOrder; + } else if (headerRow < 0) { + block.minOrder = std::min(block.minOrder, info.obsOrder); + } + } + std::sort(block.members.begin(), block.members.end(), + [](const RowInfo &a, const RowInfo &b) { return a.obsOrder < b.obsOrder; }); + blocks.insert(block.key, block); + } + + QVector order; + order.reserve(blocks.size()); + for (const GroupBlock &block : blocks) { + order.push_back(block); + } + std::sort(order.begin(), order.end(), + [](const GroupBlock &a, const GroupBlock &b) { return a.minOrder < b.minOrder; }); + + int fromIdx = -1; + for (int i = 0; i < order.size(); ++i) { + if (order[i].key == fromKey) { + fromIdx = i; + break; + } + } + if (fromIdx < 0) { + if (error) *error = "Group not found for reorder."; + return false; + } + if (order.isEmpty()) return true; + const int targetIdx = std::clamp(position - 1, 0, static_cast(order.size() - 1)); + if (fromIdx == targetIdx) return true; + + GroupBlock moving = order.takeAt(fromIdx); + int insertIdx = targetIdx; + if (fromIdx < targetIdx) insertIdx--; + if (insertIdx < 0) insertIdx = 0; + if (insertIdx > order.size()) insertIdx = order.size(); + order.insert(insertIdx, moving); + + QVector obsIds; + QVector orderValues; + int counter = 1; + for (const GroupBlock &block : order) { + for (const RowInfo &member : block.members) { + obsIds << member.obsId; + orderValues << counter++; + } + } + + QString err; + if (!db_->updateObsOrderByObservationId(tableName_, obsIds, orderValues, &err)) { + if (error) *error = err; + return false; + } + return true; + } + + bool moveGroupToTopRow(int fromRow, QString *error) { + const QString fromKey = groupKeyForRow(fromRow); + if (fromKey.isEmpty()) { + if (error) *error = "Group not found."; + return false; + } + + const int obsIdCol = columnIndex("OBSERVATION_ID"); + const int obsOrderCol = columnIndex("OBS_ORDER"); + if (obsIdCol < 0 || obsOrderCol < 0) { + if (error) *error = "OBSERVATION_ID/OBS_ORDER columns missing."; + return false; + } + + struct GroupBlock { + QString key; + int minOrder = 0; + QVector members; + }; + + QHash blocks; + for (auto it = groupRowsByKey_.begin(); it != groupRowsByKey_.end(); ++it) { + GroupBlock block; + block.key = it.key(); + block.minOrder = std::numeric_limits::max(); + const int headerRow = groupHeaderRowByKey_.value(block.key, -1); + for (int row : it.value()) { + QStandardItem *obsItem = model_->item(row, obsIdCol); + QStandardItem *orderItem = model_->item(row, obsOrderCol); + if (!obsItem || !orderItem) continue; + RowInfo info; + info.row = row; + info.obsId = obsItem->data(Qt::UserRole + 1).toString(); + info.obsOrder = orderItem->data(Qt::UserRole + 1).toInt(); + block.members.append(info); + if (row == headerRow) { + block.minOrder = info.obsOrder; + } else if (block.minOrder == std::numeric_limits::max()) { + block.minOrder = info.obsOrder; + } else if (headerRow < 0) { + block.minOrder = std::min(block.minOrder, info.obsOrder); + } + } + std::sort(block.members.begin(), block.members.end(), + [](const RowInfo &a, const RowInfo &b) { return a.obsOrder < b.obsOrder; }); + blocks.insert(block.key, block); + } + + QVector order; + order.reserve(blocks.size()); + for (const GroupBlock &block : blocks) { + order.push_back(block); + } + std::sort(order.begin(), order.end(), + [](const GroupBlock &a, const GroupBlock &b) { return a.minOrder < b.minOrder; }); + + int fromIdx = -1; + for (int i = 0; i < order.size(); ++i) { + if (order[i].key == fromKey) { + fromIdx = i; + break; + } + } + if (fromIdx < 0) { + if (error) *error = "Group not found for reorder."; + return false; + } + if (fromIdx == 0) return true; + + GroupBlock moving = order.takeAt(fromIdx); + order.insert(0, moving); + + QVector obsIds; + QVector orderValues; + int counter = 1; + for (const GroupBlock &block : order) { + for (const RowInfo &member : block.members) { + obsIds << member.obsId; + orderValues << counter++; + } + } + + QString err; + if (!db_->updateObsOrderByObservationId(tableName_, obsIds, orderValues, &err)) { + if (error) *error = err; + return false; + } + return true; + } + + bool deleteGroupForRow(int row, QString *error) { + const QString key = groupKeyForRow(row); + if (key.isEmpty()) { + if (error) *error = "Group not found."; + return false; + } + const QList members = groupRowsByKey_.value(key); + if (members.isEmpty()) return true; + int setId = -1; + const int setIdCol = columnIndex("SET_ID"); + if (setIdCol >= 0) { + QStandardItem *setItem = model_->item(members.first(), setIdCol); + if (setItem) setId = setItem->data(Qt::UserRole + 1).toInt(); + } + QList keyList; + keyList.reserve(members.size()); + for (int memberRow : members) { + QVariantMap keyValues = keyValuesForRow(memberRow); + if (!keyValues.isEmpty()) { + keyList.append(keyValues); + } + } + QString err; + for (const QVariantMap &keyValues : keyList) { + if (!db_->deleteRecordByKey(tableName_, keyValues, &err)) { + if (error) *error = err; + return false; + } + } + if (setId >= 0) { + QString normError; + if (!normalizeObsOrderForSet(setId, &normError)) { + if (error) *error = normError; + return false; + } + } + return true; + } + + bool normalizeObsOrderForSet(int setId, QString *error) { + if (!db_ || !db_->isOpen()) { + if (error) *error = "Not connected"; + return false; + } + if (setId < 0) return true; + QList cols; + if (!db_->loadColumns(tableName_, cols, error)) { + return false; + } + QVector> rows; + if (!db_->fetchRows(tableName_, cols, "SET_ID", QString::number(setId), + "", "", "OBS_ORDER", rows, error)) { + return false; + } + int obsIdCol = -1; + for (int i = 0; i < cols.size(); ++i) { + if (cols[i].name.compare("OBSERVATION_ID", Qt::CaseInsensitive) == 0) { + obsIdCol = i; + break; + } + } + if (obsIdCol < 0) return true; + QVector obsIds; + QVector orderValues; + obsIds.reserve(rows.size()); + orderValues.reserve(rows.size()); + for (int i = 0; i < rows.size(); ++i) { + if (obsIdCol >= rows[i].size()) continue; + const QVariant obsId = rows[i].at(obsIdCol); + obsIds << obsId; + orderValues << (i + 1); + } + QString err; + if (!db_->updateObsOrderByObservationId(tableName_, obsIds, orderValues, &err)) { + if (error) *error = err; + return false; + } + return true; + } + + bool moveRowAfter(int from, int to, QString *errorOut) { + if (!allowReorder_) return false; + if (!searchEdit_->text().trimmed().isEmpty()) { + showInfo(this, "Reorder disabled", + "Clear the search filter before reordering."); + return false; + } + if (from < 0 || to < 0 || from >= model_->rowCount() || to >= model_->rowCount()) return false; + if (from == to) return true; + if (!db_ || !db_->isOpen()) { + showWarning(this, "Reorder failed", "Not connected"); + return false; + } + + const SwapInfo src = swapInfoForRow(from); + const SwapInfo dst = swapInfoForRow(to); + if (!src.valid || !dst.valid) { + showWarning(this, "Reorder failed", + "Missing OBSERVATION_ID/OBS_ORDER values."); + return false; + } + + const int setIdCol = columnIndex("SET_ID"); + int setId = -1; + if (setIdCol >= 0) { + QStandardItem *setItem = model_->item(from, setIdCol); + if (setItem) setId = setItem->data(Qt::UserRole + 1).toInt(); + } + + ViewState state = captureViewState(); + const bool groupMove = groupingEnabled_ && isGroupHeaderRow(from) && + !expandedGroups_.contains(groupKeyForRow(from)); + + QString error; + if (groupMove) { + if (!moveGroupAfterRow(from, to, &error)) { + if (errorOut) *errorOut = error; + showWarning(this, "Reorder failed", error.isEmpty() ? "Group move failed." : error); + refresh(); + return false; + } + } else { + if (!moveSingleAfterRow(from, to, setId, &error)) { + if (errorOut) *errorOut = error; + showWarning(this, "Reorder failed", error.isEmpty() ? "Move failed." : error); + refresh(); + return false; + } + } + + refreshWithState(state); + emit dataMutated(); + return true; + } + + bool moveSingleAfterRowWithRefresh(int fromRow, int toRow, QString *errorOut) { + if (!allowReorder_) return false; + if (!searchEdit_->text().trimmed().isEmpty()) { + showInfo(this, "Reorder disabled", + "Clear the search filter before reordering."); + return false; + } + if (fromRow < 0 || toRow < 0 || fromRow >= model_->rowCount() || toRow >= model_->rowCount()) { + return false; + } + if (fromRow == toRow) return true; + if (!db_ || !db_->isOpen()) { + showWarning(this, "Reorder failed", "Not connected"); + return false; + } + + const int setIdCol = columnIndex("SET_ID"); + int setId = -1; + if (setIdCol >= 0) { + QStandardItem *setItem = model_->item(fromRow, setIdCol); + if (setItem) setId = setItem->data(Qt::UserRole + 1).toInt(); + } + + ViewState state = captureViewState(); + QString error; + if (!moveSingleAfterRow(fromRow, toRow, setId, &error)) { + if (errorOut) *errorOut = error; + showWarning(this, "Reorder failed", error.isEmpty() ? "Move failed." : error); + refresh(); + return false; + } + refreshWithState(state); + emit dataMutated(); + return true; + } + + int previousVisibleRow(int row) const { + for (int r = row - 1; r >= 0; --r) { + if (!view_->isRowHidden(r)) return r; + } + return -1; + } + + int nextVisibleRow(int row) const { + for (int r = row + 1; r < model_->rowCount(); ++r) { + if (!view_->isRowHidden(r)) return r; + } + return -1; + } + + int firstVisibleRow() const { + for (int r = 0; r < model_->rowCount(); ++r) { + if (!view_->isRowHidden(r)) return r; + } + return -1; + } + + int lastVisibleRow() const { + for (int r = model_->rowCount() - 1; r >= 0; --r) { + if (!view_->isRowHidden(r)) return r; + } + return -1; + } + + void applyHiddenColumns() { + if (!view_ || columns_.isEmpty()) return; + for (int i = 0; i < columns_.size(); ++i) { + const QString name = columns_.at(i).name.toUpper(); + const bool hide = hiddenColumns_.contains(name); + view_->setColumnHidden(i, hide); + } + } + + void applyColumnOrderRules() { + if (!view_ || columns_.isEmpty() || columnAfterRules_.isEmpty()) return; + QHeaderView *header = view_->horizontalHeader(); + for (const auto &rule : columnAfterRules_) { + const int anchor = columnIndex(rule.first); + const int column = columnIndex(rule.second); + if (anchor < 0 || column < 0) continue; + const int anchorVisual = header->visualIndex(anchor); + int columnVisual = header->visualIndex(column); + if (anchorVisual < 0 || columnVisual < 0) continue; + const int target = anchorVisual + 1; + if (columnVisual == target) continue; + header->moveSection(columnVisual, target); + } + } + + void updateGroupSequenceNumbers() { + if (!view_ || !model_) return; + if (!groupingEnabled_ || groupRowsByKey_.isEmpty()) { + for (int row = 0; row < model_->rowCount(); ++row) { + model_->setHeaderData(row, Qt::Vertical, QVariant()); + } + return; + } + + const int obsOrderCol = columnIndex("OBS_ORDER"); + struct GroupOrder { + QString key; + int order = 0; + }; + QVector groupOrder; + groupOrder.reserve(groupRowsByKey_.size()); + for (auto it = groupRowsByKey_.begin(); it != groupRowsByKey_.end(); ++it) { + int minOrder = std::numeric_limits::max(); + const int headerRow = groupHeaderRowByKey_.value(it.key(), -1); + if (obsOrderCol >= 0) { + for (int row : it.value()) { + QStandardItem *orderItem = model_->item(row, obsOrderCol); + if (!orderItem) continue; + const int orderVal = orderItem->data(Qt::UserRole + 1).toInt(); + if (row == headerRow) { + minOrder = orderVal; + break; + } + if (orderVal < minOrder) minOrder = orderVal; + } + } else if (!it.value().isEmpty()) { + minOrder = it.value().first(); + } + if (minOrder == std::numeric_limits::max()) minOrder = 0; + groupOrder.append({it.key(), minOrder}); + } + std::sort(groupOrder.begin(), groupOrder.end(), + [](const GroupOrder &a, const GroupOrder &b) { return a.order < b.order; }); + + QHash seqByKey; + int seq = 1; + for (const GroupOrder &group : groupOrder) { + seqByKey.insert(group.key, seq++); + } + + for (int row = 0; row < model_->rowCount(); ++row) { + const QString key = groupKeyByRow_.value(row); + if (key.isEmpty()) { + model_->setHeaderData(row, Qt::Vertical, QVariant()); + } else { + const int num = seqByKey.value(key, row + 1); + model_->setHeaderData(row, Qt::Vertical, QString::number(num)); + } + } + } + + QString headerSettingsKey() const { + if (tableName_.isEmpty()) return QString(); + return QString("tableHeaders/%1/state").arg(tableName_); + } + + QString groupingSettingsKey(const QString &suffix) const { + if (tableName_.isEmpty()) return QString(); + return QString("tableGrouping/%1/%2").arg(tableName_, suffix); + } + + bool restoreHeaderState() { + if (!view_ || tableName_.isEmpty()) return false; + QSettings settings(kSettingsOrg, kSettingsApp); + const QByteArray state = settings.value(headerSettingsKey()).toByteArray(); + if (state.isEmpty()) return false; + headerStateUpdating_ = true; + const bool ok = view_->horizontalHeader()->restoreState(state); + headerStateUpdating_ = false; + if (ok) { + headerStateLoaded_ = true; + } + return ok; + } + + void scheduleHeaderStateSave() { + if (headerStateUpdating_ || !headerSaveTimer_) return; + headerSaveTimer_->start(200); + } + + void saveHeaderState() { + if (headerStateUpdating_ || !view_ || tableName_.isEmpty()) return; + QSettings settings(kSettingsOrg, kSettingsApp); + settings.setValue(headerSettingsKey(), view_->horizontalHeader()->saveState()); + headerStateLoaded_ = true; + } + + void loadGroupingState() { + manualUngroupObsIds_.clear(); + manualGroupKeyByObsId_.clear(); + selectedObsIdByHeader_.clear(); + if (tableName_.isEmpty()) return; + QSettings settings(kSettingsOrg, kSettingsApp); + const QStringList ungrouped = settings.value(groupingSettingsKey("manualUngroup")).toStringList(); + for (const QString &obsId : ungrouped) { + if (!obsId.trimmed().isEmpty()) manualUngroupObsIds_.insert(obsId.trimmed()); + } + const QStringList groups = settings.value(groupingSettingsKey("manualGroups")).toStringList(); + for (const QString &entry : groups) { + const int idx = entry.indexOf('='); + if (idx <= 0) continue; + const QString obsId = entry.left(idx).trimmed(); + const QString key = entry.mid(idx + 1).trimmed(); + if (obsId.isEmpty() || key.isEmpty()) continue; + manualGroupKeyByObsId_.insert(obsId, key); + } + const QStringList selected = settings.value(groupingSettingsKey("selectedObs")).toStringList(); + for (const QString &entry : selected) { + const int idx = entry.indexOf('='); + if (idx <= 0) continue; + const QString headerObsId = entry.left(idx).trimmed(); + const QString selectedObsId = entry.mid(idx + 1).trimmed(); + if (headerObsId.isEmpty() || selectedObsId.isEmpty()) continue; + selectedObsIdByHeader_.insert(headerObsId, selectedObsId); + } + } + + void saveGroupingState() { + if (tableName_.isEmpty()) return; + QSettings settings(kSettingsOrg, kSettingsApp); + QStringList ungrouped = manualUngroupObsIds_.values(); + ungrouped.sort(); + settings.setValue(groupingSettingsKey("manualUngroup"), ungrouped); + QStringList groups; + groups.reserve(manualGroupKeyByObsId_.size()); + for (auto it = manualGroupKeyByObsId_.begin(); it != manualGroupKeyByObsId_.end(); ++it) { + groups << QString("%1=%2").arg(it.key(), it.value()); + } + groups.sort(); + settings.setValue(groupingSettingsKey("manualGroups"), groups); + + QStringList selected; + selected.reserve(selectedObsIdByHeader_.size()); + for (auto it = selectedObsIdByHeader_.begin(); it != selectedObsIdByHeader_.end(); ++it) { + if (it.key().trimmed().isEmpty() || it.value().trimmed().isEmpty()) continue; + selected << QString("%1=%2").arg(it.key(), it.value()); + } + selected.sort(); + settings.setValue(groupingSettingsKey("selectedObs"), selected); + } + + void deleteRow(int row) { + if (!allowDelete_) return; + if (row < 0 || row >= model_->rowCount()) return; + if (groupingEnabled_ && isGroupHeaderRow(row) && !expandedGroups_.contains(groupKeyForRow(row))) { + if (QMessageBox::question(this, "Delete Target Group", + "Delete all targets in this group?", + QMessageBox::Yes | QMessageBox::No) != QMessageBox::Yes) { + return; + } + QString error; + if (!deleteGroupForRow(row, &error)) { + showWarning(this, "Delete failed", error); + return; + } + refreshWithState(captureViewState()); + emit dataMutated(); + return; + } + + if (QMessageBox::question(this, "Delete Target", "Delete the selected target?", + QMessageBox::Yes | QMessageBox::No) != QMessageBox::Yes) { + return; + } + QVariantMap keyValues = keyValuesForRow(row); + if (keyValues.isEmpty()) { + showWarning(this, "Delete failed", "Missing primary key values."); + return; + } + QString error; + if (!db_->deleteRecordByKey(tableName_, keyValues, &error)) { + showWarning(this, "Delete failed", error); + return; + } + const int setIdCol = columnIndex("SET_ID"); + const int obsOrderCol = columnIndex("OBS_ORDER"); + int setId = -1; + int oldPos = -1; + if (setIdCol >= 0 && obsOrderCol >= 0) { + QStandardItem *setItem = model_->item(row, setIdCol); + QStandardItem *orderItem = model_->item(row, obsOrderCol); + if (setItem) setId = setItem->data(Qt::UserRole + 1).toInt(); + if (orderItem) oldPos = orderItem->data(Qt::UserRole + 1).toInt(); + } + + ViewState state = captureViewState(); + suppressItemChange_ = true; + model_->removeRow(row); + suppressItemChange_ = false; + + if (setId >= 0 && oldPos >= 0) { + QString shiftError; + if (!db_->shiftObsOrderAfterDelete(tableName_, setId, oldPos, &shiftError)) { + showWarning(this, "Reorder failed", shiftError); + refresh(); + return; + } + } + refreshWithState(state); + emit dataMutated(); + } + + void duplicateRow(int row) { + if (!db_ || !db_->isOpen()) return; + if (row < 0 || row >= model_->rowCount()) return; + + const int setIdCol = columnIndex("SET_ID"); + const int obsOrderCol = columnIndex("OBS_ORDER"); + if (setIdCol < 0 || obsOrderCol < 0) { + // Fallback: simple duplicate to end if ordering columns missing. + QVariantMap values; + QSet nullColumns; + for (int c = 0; c < columns_.size(); ++c) { + const ColumnMeta &meta = columns_.at(c); + if (meta.isAutoIncrement()) continue; + QStandardItem *item = model_->item(row, c); + if (!item) continue; + const bool isNull = item->data(Qt::UserRole + 2).toBool(); + const QVariant value = item->data(Qt::UserRole + 1); + if (isNull) { + nullColumns.insert(meta.name); + } else { + values.insert(meta.name, value); + } + } + QString error; + if (!insertRecord(values, nullColumns, &error)) { + showWarning(this, "Duplicate failed", error); + return; + } + refreshWithState(captureViewState()); + emit dataMutated(); + return; + } + + QStandardItem *setItem = model_->item(row, setIdCol); + if (!setItem) return; + const int setId = setItem->data(Qt::UserRole + 1).toInt(); + if (setId <= 0) return; + + const QString groupKey = groupKeyForRow(row); + const bool groupDuplicate = groupingEnabled_ && isGroupHeaderRow(row) && + !expandedGroups_.contains(groupKey); + + QVector sourceRows; + if (groupDuplicate && !groupKey.isEmpty()) { + for (int memberRow : groupRowsByKey_.value(groupKey)) { + sourceRows.append(memberRow); + } + } else { + sourceRows.append(row); + } + if (sourceRows.isEmpty()) return; + + std::sort(sourceRows.begin(), sourceRows.end(), [&](int a, int b) { + QStandardItem *orderA = model_->item(a, obsOrderCol); + QStandardItem *orderB = model_->item(b, obsOrderCol); + const int oa = orderA ? orderA->data(Qt::UserRole + 1).toInt() : 0; + const int ob = orderB ? orderB->data(Qt::UserRole + 1).toInt() : 0; + return oa < ob; + }); + + QStandardItem *orderItem = model_->item(sourceRows.last(), obsOrderCol); + if (!orderItem) return; + const int lastOrder = orderItem->data(Qt::UserRole + 1).toInt(); + const int insertPos = lastOrder + 1; + const int count = sourceRows.size(); + + QString error; + if (!db_->shiftObsOrderForInsert(tableName_, setId, insertPos, count, &error)) { + showWarning(this, "Duplicate failed", error.isEmpty() ? "Failed to insert target(s)." : error); + return; + } + + bool insertedAll = true; + for (int i = 0; i < sourceRows.size(); ++i) { + const int srcRow = sourceRows.at(i); + QVariantMap values; + QSet nullColumns; + for (int c = 0; c < columns_.size(); ++c) { + const ColumnMeta &meta = columns_.at(c); + if (meta.isAutoIncrement()) continue; + QStandardItem *item = model_->item(srcRow, c); + if (!item) continue; + const bool isNull = item->data(Qt::UserRole + 2).toBool(); + const QVariant value = item->data(Qt::UserRole + 1); + if (isNull) { + nullColumns.insert(meta.name); + } else { + values.insert(meta.name, value); + } + } + values.insert("OBS_ORDER", insertPos + i); + nullColumns.remove("OBS_ORDER"); + if (!insertRecord(values, nullColumns, &error)) { + insertedAll = false; + showWarning(this, "Duplicate failed", error); + break; + } + } + + if (!insertedAll) { + QString normErr; + normalizeObsOrderForSet(setId, &normErr); + refreshWithState(captureViewState()); + emit dataMutated(); + return; + } + + ViewState state = captureViewState(); + refreshWithState(state); + emit dataMutated(); + + const QStringList newObsIds = obsIdsForObsOrderRange(setId, insertPos, count); + if (!newObsIds.isEmpty()) { + if (groupDuplicate) { + const QString groupId = + QString("MANUAL:%1").arg(QUuid::createUuid().toString(QUuid::WithoutBraces)); + for (const QString &newObsId : newObsIds) { + manualUngroupObsIds_.remove(newObsId); + manualGroupKeyByObsId_.insert(newObsId, groupId); + } + saveGroupingState(); + applyGrouping(); + } else { + int groupSize = 1; + if (groupingEnabled_ && !groupKey.isEmpty()) { + groupSize = groupRowsByKey_.value(groupKey).size(); + } + if (groupSize <= 1) { + for (const QString &newObsId : newObsIds) { + manualUngroupObsIds_.insert(newObsId); + manualGroupKeyByObsId_.remove(newObsId); + } + saveGroupingState(); + applyGrouping(); + } + } + } + } + + QVariantMap keyValuesForRow(int row) const { + QVariantMap keyValues; + for (int c = 0; c < columns_.size(); ++c) { + if (!columns_[c].isPrimaryKey()) continue; + QStandardItem *item = model_->item(row, c); + if (!item) continue; + const QVariant keyValue = item->data(Qt::UserRole + 1); + const bool keyIsNull = item->data(Qt::UserRole + 2).toBool(); + if (!keyValue.isValid() || keyIsNull) return QVariantMap(); + keyValues.insert(columns_[c].name, keyValue); + } + return keyValues; + } + + int columnIndex(const QString &name) const { + for (int i = 0; i < columns_.size(); ++i) { + if (columns_[i].name.compare(name, Qt::CaseInsensitive) == 0) return i; + } + return -1; + } + + bool isColumnBulkEditable(int col) const { + if (col < 0 || col >= columns_.size()) return false; + const ColumnMeta &meta = columns_.at(col); + if (meta.isPrimaryKey() || meta.isAutoIncrement()) return false; + if (hiddenColumns_.contains(meta.name.toUpper())) return false; + return true; + } + + QStringList editableColumnsForBulkEdit() const { + QStringList result; + for (const ColumnMeta &meta : columns_) { + if (meta.isPrimaryKey() || meta.isAutoIncrement()) continue; + if (hiddenColumns_.contains(meta.name.toUpper())) continue; + result << meta.name; + } + return result; + } + + QStringList obsIdsInView() const { + QStringList obsIds; + const int obsIdCol = columnIndex("OBSERVATION_ID"); + if (obsIdCol < 0) return obsIds; + QSet seen; + for (int row = 0; row < model_->rowCount(); ++row) { + QStandardItem *item = model_->item(row, obsIdCol); + if (!item) continue; + const QString obsId = item->data(Qt::UserRole + 1).toString(); + if (obsId.isEmpty() || seen.contains(obsId)) continue; + seen.insert(obsId); + obsIds.append(obsId); + } + return obsIds; + } + + QStringList obsIdsForObsOrderRange(int setId, int startOrder, int count) const { + QStringList obsIds; + if (count <= 0) return obsIds; + const int obsIdCol = columnIndex("OBSERVATION_ID"); + const int obsOrderCol = columnIndex("OBS_ORDER"); + const int setIdCol = columnIndex("SET_ID"); + if (obsIdCol < 0 || obsOrderCol < 0 || setIdCol < 0) return obsIds; + QMap ordered; + const int endOrder = startOrder + count - 1; + for (int row = 0; row < model_->rowCount(); ++row) { + QStandardItem *setItem = model_->item(row, setIdCol); + QStandardItem *orderItem = model_->item(row, obsOrderCol); + QStandardItem *obsItem = model_->item(row, obsIdCol); + if (!setItem || !orderItem || !obsItem) continue; + const int rowSetId = setItem->data(Qt::UserRole + 1).toInt(); + if (rowSetId != setId) continue; + const int orderVal = orderItem->data(Qt::UserRole + 1).toInt(); + if (orderVal < startOrder || orderVal > endOrder) continue; + const QString obsId = obsItem->data(Qt::UserRole + 1).toString(); + if (!obsId.isEmpty()) ordered.insert(orderVal, obsId); + } + for (auto it = ordered.begin(); it != ordered.end(); ++it) { + obsIds.append(it.value()); + } + return obsIds; + } + + void revertItem(QStandardItem *item, const QVariant &oldValue, bool oldIsNull) { + suppressItemChange_ = true; + item->setData(oldIsNull ? QVariant() : oldValue, Qt::EditRole); + item->setData(oldIsNull ? QVariant() : oldValue, Qt::UserRole + 1); + item->setData(oldIsNull, Qt::UserRole + 2); + item->setText(displayForVariant(oldValue, oldIsNull)); + item->setForeground(QBrush(oldIsNull ? view_->palette().color(QPalette::Disabled, QPalette::Text) + : view_->palette().color(QPalette::Text))); + suppressItemChange_ = false; + } + + bool insertRecord(const QVariantMap &values, const QSet &nullColumns, QString *error) { + if (!db_) { + if (error) *error = "Not connected"; + return false; + } + return db_->insertRecord(tableName_, columns_, values, nullColumns, error); + } + + bool updateRecord(const QVariantMap &values, const QSet &nullColumns, + const QVariantMap &keyValues, QString *error) { + if (!db_) { + if (error) *error = "Not connected"; + return false; + } + return db_->updateRecord(tableName_, columns_, values, nullColumns, keyValues, error); + } + + DbClient *db_ = nullptr; + QString tableName_; + QList columns_; + QStandardItemModel *model_ = nullptr; + ReorderTableView *view_ = nullptr; + + QPushButton *refreshButton_ = nullptr; + QPushButton *addButton_ = nullptr; + QLabel *statusLabel_ = nullptr; + + QLabel *searchLabel_ = nullptr; + QLineEdit *searchEdit_ = nullptr; + QPushButton *searchApply_ = nullptr; + QPushButton *searchClear_ = nullptr; + QString searchColumn_; + QString fixedFilterColumn_; + QString fixedFilterValue_; + QString orderByColumn_; + bool sortingEnabled_ = true; + bool allowReorder_ = false; + bool allowDelete_ = false; + bool allowColumnHeaderBulkEdit_ = false; + bool quickAddEnabled_ = false; + bool quickAddInsertAtTop_ = false; + std::function &)> normalizer_; + std::function &, QString *)> quickAddBuilder_; + QStringList hiddenColumns_; + QVector> columnAfterRules_; + + bool suppressItemChange_ = false; + bool headerStateLoaded_ = false; + bool headerStateUpdating_ = false; + QTimer *headerSaveTimer_ = nullptr; + bool headerRulesPending_ = false; + + bool groupingEnabled_ = false; + QHash> groupRowsByKey_; + QHash groupHeaderRowByKey_; + QHash groupKeyByRow_; + QSet expandedGroups_; + QSet manualUngroupObsIds_; + QHash manualGroupKeyByObsId_; + QHash selectedObsIdByHeader_; + bool groupingStateLoaded_ = false; + QPointer moveToDialog_; +}; + +class TimelineCanvas : public QWidget { + Q_OBJECT +public: + explicit TimelineCanvas(QWidget *parent = nullptr) : QWidget(parent) { + setMouseTracking(true); + } + + void setData(const TimelineData &data) { + data_ = data; + setMinimumHeight(sizeHint().height()); + updateGeometry(); + update(); + } + + void clear() { + data_ = TimelineData(); + selectedObsId_.clear(); + setMinimumHeight(sizeHint().height()); + updateGeometry(); + update(); + } + + void setSelectedObsId(const QString &obsId) { + if (selectedObsId_ == obsId) return; + selectedObsId_ = obsId; + setMinimumHeight(sizeHint().height()); + updateGeometry(); + update(); + } + + QSize sizeHint() const override { + int height = topMargin_ + bottomMargin_; + if (data_.targets.isEmpty()) { + height += rowHeight_; + } else { + for (int i = 0; i < data_.targets.size(); ++i) { + height += rowHeightForIndex(i); + } + } + const int width = leftMargin_ + rightMargin_ + 720; + return QSize(width, height); + } + +signals: + void targetSelected(const QString &obsId); + void targetReorderRequested(const QString &fromObsId, const QString &toObsId); + void flagClicked(const QString &obsId, const QString &flagText); + void contextMenuRequested(const QString &obsId, const QPoint &globalPos); + void exptimeEditRequested(const QString &obsId); + +protected: + void mouseDoubleClickEvent(QMouseEvent *event) override { + if (data_.targets.isEmpty()) return; + const int index = rowAt(event->pos()); + if (index < 0 || index >= data_.targets.size()) return; + const TimelineTarget &target = data_.targets.at(index); + QVector> segments = target.segments; + if (segments.isEmpty() && target.startUtc.isValid() && target.endUtc.isValid()) { + segments.append({target.startUtc, target.endUtc}); + } + if (segments.isEmpty()) return; + if (data_.timesUtc.isEmpty()) return; + const qint64 t0 = data_.timesUtc.first().toMSecsSinceEpoch(); + const qint64 t1 = data_.timesUtc.last().toMSecsSinceEpoch(); + const QRect plotRect = plotArea(); + const QRect rowRect = rowArea(plotRect, index); + for (const auto &segment : segments) { + const qint64 s = segment.first.toMSecsSinceEpoch(); + const qint64 e = segment.second.toMSecsSinceEpoch(); + if (e <= t0 || s >= t1) continue; + const double x1 = timeToX(std::max(s, t0), t0, t1, rowRect); + const double x2 = timeToX(std::min(e, t1), t0, t1, rowRect); + const double y = rowRect.center().y() - barHeight_ / 2.0; + QRectF bar(x1, y, x2 - x1, barHeight_); + if (bar.contains(event->pos())) { + if (!target.obsId.isEmpty()) { + emit exptimeEditRequested(target.obsId); + } + event->accept(); + return; + } + } + QWidget::mouseDoubleClickEvent(event); + } + void contextMenuEvent(QContextMenuEvent *event) override { + if (data_.targets.isEmpty()) return; + const int index = rowAt(event->pos()); + if (index < 0 || index >= data_.targets.size()) return; + const QString obsId = data_.targets.at(index).obsId; + if (!obsId.isEmpty()) { + emit contextMenuRequested(obsId, event->globalPos()); + } + } + + void paintEvent(QPaintEvent *) override { + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.fillRect(rect(), palette().base()); + + if (data_.targets.isEmpty() || data_.timesUtc.isEmpty()) { + painter.setPen(palette().color(QPalette::Disabled, QPalette::Text)); + painter.drawText(rect(), Qt::AlignCenter, "Run OTM to view timeline."); + return; + } + + const QRect plotRect = plotArea(); + painter.setPen(palette().color(QPalette::Mid)); + painter.drawRect(plotRect.adjusted(0, 0, -1, -1)); + + const qint64 t0 = data_.timesUtc.first().toMSecsSinceEpoch(); + const qint64 t1 = data_.timesUtc.last().toMSecsSinceEpoch(); + if (t1 <= t0) return; + + drawTwilightLines(&painter, plotRect, t0, t1); + drawTimeAxis(&painter, plotRect, t0, t1); + + drawRowSeparators(&painter, plotRect); + + const double airmassMin = 1.0; + const double airmassMax = data_.airmassLimit > 0.0 ? data_.airmassLimit : 4.0; + + drawIdleGaps(&painter, plotRect, t0, t1); + + for (int i = 0; i < data_.targets.size(); ++i) { + const TimelineTarget &target = data_.targets.at(i); + const QRect rowRect = rowArea(plotRect, i); + const bool selected = (!selectedObsId_.isEmpty() && target.obsId == selectedObsId_); + const bool observed = target.observed || !target.segments.isEmpty() || + (target.startUtc.isValid() && target.endUtc.isValid()); + + QColor labelColor = palette().color(QPalette::Text); + if (!observed) { + labelColor = palette().color(QPalette::Disabled, QPalette::Text); + } else if (target.severity == 2) { + labelColor = QColor(220, 70, 70); + } else if (target.severity == 1) { + labelColor = QColor(255, 165, 0); + } + QString displayName = target.name.isEmpty() ? target.obsId : target.name; + if (!displayName.isEmpty()) { + QRegularExpression countSuffixRe("\\s*\\((\\d+)\\)\\s*$"); + displayName = displayName.left(countSuffixRe.match(displayName).capturedStart()).trimmed(); + } + const int labelWidth = leftMargin_ - 6; + const int orderWidth = 44; + const int gap = 8; + QRect orderRect(0, rowRect.top(), std::min(orderWidth, labelWidth), rowRect.height()); + QRect nameRect(orderRect.right() + gap, rowRect.top(), + std::max(0, labelWidth - orderRect.width() - gap), rowRect.height()); + if (target.obsOrder > 0) { + QColor orderColor = labelColor.darker(140); + painter.setPen(orderColor); + painter.drawText(orderRect, Qt::AlignLeft | Qt::AlignVCenter, + QString::number(target.obsOrder)); + } + painter.setPen(labelColor); + painter.drawText(nameRect, Qt::AlignRight | Qt::AlignVCenter, displayName); + + drawExposureBar(&painter, rowRect, target, t0, t1, selected, i, observed); + drawAirmassCurve(&painter, rowRect, target, t0, t1, airmassMin, airmassMax, selected, i, observed); + if (selected) { + drawAirmassCurveLabels(&painter, rowRect, target, t0, t1, airmassMin, airmassMax); + } + + if (!target.flag.trimmed().isEmpty()) { + QRect flagRect(plotRect.right() + 6, rowRect.top(), rightLabelWidth_, rowRect.height()); + QFontMetrics fm(painter.font()); + const QString label = fm.elidedText(target.flag.trimmed(), Qt::ElideRight, flagRect.width() - 4); + painter.setPen(labelColor); + painter.drawText(flagRect, Qt::AlignVCenter | Qt::AlignLeft, label); + } + } + + drawDropIndicator(&painter, plotRect); + } + + void mousePressEvent(QMouseEvent *event) override { + if (data_.targets.isEmpty()) return; + pressPos_ = event->pos(); + pressIndex_ = rowAt(event->pos()); + dragging_ = false; + if (pressIndex_ >= 0 && pressIndex_ < data_.targets.size()) { + const QRect plotRect = plotArea(); + const QRect rowRect = rowArea(plotRect, pressIndex_); + const QString obsId = data_.targets.at(pressIndex_).obsId; + const QString flagText = data_.targets.at(pressIndex_).flag.trimmed(); + if (!flagText.isEmpty()) { + QRect flagRect(plotRect.right() + 6, rowRect.top(), rightLabelWidth_, rowRect.height()); + if (flagRect.contains(event->pos())) { + emit flagClicked(obsId, flagText); + } + } + if (!obsId.isEmpty()) { + selectedObsId_ = obsId; + emit targetSelected(obsId); + update(); + } + } + } + + void mouseMoveEvent(QMouseEvent *event) override { + if (pressIndex_ < 0) return; + if (!dragging_) { + if ((event->pos() - pressPos_).manhattanLength() < QApplication::startDragDistance()) { + return; + } + dragging_ = true; + setCursor(Qt::ClosedHandCursor); + } + int hover = rowAt(event->pos()); + if (hover < 0 && !data_.targets.isEmpty()) { + const QRect plotRect = plotArea(); + if (event->pos().y() < plotRect.top()) { + hover = 0; + } else if (event->pos().y() > plotRect.bottom()) { + hover = data_.targets.size() - 1; + } + } + if (hover >= 0 && hover < data_.targets.size()) { + const QRect plotRect = plotArea(); + const QRect hoverRect = rowArea(plotRect, hover); + dragInsertAbove_ = event->pos().y() < hoverRect.center().y(); + } else { + dragInsertAbove_ = false; + } + if (hover != dragHoverIndex_) { + dragHoverIndex_ = hover; + update(); + } + } + + void mouseReleaseEvent(QMouseEvent *event) override { + if (dragging_) { + int targetIndex = rowAt(event->pos()); + if (targetIndex < 0) targetIndex = dragHoverIndex_; + if (pressIndex_ >= 0 && targetIndex >= 0 && pressIndex_ != targetIndex) { + const QString fromObs = data_.targets.at(pressIndex_).obsId; + if (!fromObs.isEmpty()) { + const QRect plotRect = plotArea(); + const QRect targetRect = rowArea(plotRect, targetIndex); + const bool insertAbove = event->pos().y() < targetRect.center().y(); + if (insertAbove) { + if (targetIndex == 0) { + emit targetReorderRequested(fromObs, QString()); + } else { + const QString prevObs = data_.targets.at(targetIndex - 1).obsId; + if (!prevObs.isEmpty()) { + emit targetReorderRequested(fromObs, prevObs); + } + } + } else { + const QString toObs = data_.targets.at(targetIndex).obsId; + if (!toObs.isEmpty()) { + emit targetReorderRequested(fromObs, toObs); + } + } + } + } + } + dragging_ = false; + pressIndex_ = -1; + dragHoverIndex_ = -1; + dragInsertAbove_ = false; + unsetCursor(); + update(); + } + +private: + TimelineData data_; + QString selectedObsId_; + QPoint pressPos_; + int pressIndex_ = -1; + bool dragging_ = false; + int dragHoverIndex_ = -1; + bool dragInsertAbove_ = false; + + const int leftMargin_ = 200; + const int rightLabelWidth_ = 160; + const int rightMargin_ = rightLabelWidth_ + 12; + const int topMargin_ = 24; + const int bottomMargin_ = 28; + const int rowHeight_ = 26; + const int selectedRowExtra_ = 70; + const int barHeight_ = 10; + + QRect plotArea() const { + return QRect(leftMargin_, topMargin_, + width() - leftMargin_ - rightMargin_, + height() - topMargin_ - bottomMargin_); + } + + int rowHeightForIndex(int index) const { + if (index < 0 || index >= data_.targets.size()) return rowHeight_; + const TimelineTarget &target = data_.targets.at(index); + if (!selectedObsId_.isEmpty() && target.obsId == selectedObsId_) { + return rowHeight_ + selectedRowExtra_; + } + return rowHeight_; + } + + double timeToX(qint64 t, qint64 t0, qint64 t1, const QRect &plotRect) const { + const double frac = double(t - t0) / double(t1 - t0); + return plotRect.left() + frac * plotRect.width(); + } + + QColor colorForTarget(int index, const QString &obsId) const { + const uint hash = qHash(obsId); + const int base = (static_cast(hash % 360) + index * 137) % 360; + QColor color; + color.setHsv(base, 110, 210); + return color; + } + + QColor displayColorForTarget(const TimelineTarget &target, int index, bool observed) const { + if (!observed) { + return palette().color(QPalette::Disabled, QPalette::Text); + } + if (target.severity == 2) return QColor(220, 70, 70); + if (target.severity == 1) return QColor(255, 165, 0); + return colorForTarget(index, target.obsId); + } + + QRect rowArea(const QRect &plotRect, int index) const { + int y = plotRect.top(); + for (int i = 0; i < index; ++i) { + y += rowHeightForIndex(i); + } + return QRect(plotRect.left(), + y, + plotRect.width(), + rowHeightForIndex(index)); + } + + int rowAt(const QPoint &pos) const { + const QRect plotRect = plotArea(); + if (pos.y() < plotRect.top() || pos.y() > plotRect.bottom()) return -1; + int y = plotRect.top(); + for (int i = 0; i < data_.targets.size(); ++i) { + const int h = rowHeightForIndex(i); + if (pos.y() >= y && pos.y() < y + h) { + return i; + } + y += h; + } + return -1; + } + + void drawTwilightLines(QPainter *painter, const QRect &plotRect, qint64 t0, qint64 t1) { + struct Line { + QDateTime time; + QString label; + }; + QVector lines; + if (data_.twilightEvening16.isValid()) lines.append({data_.twilightEvening16, "Twilight -16"}); + if (data_.twilightEvening12.isValid()) lines.append({data_.twilightEvening12, "Twilight -12"}); + if (data_.twilightMorning12.isValid()) lines.append({data_.twilightMorning12, "Twilight -12"}); + if (data_.twilightMorning16.isValid()) lines.append({data_.twilightMorning16, "Twilight -16"}); + + QPen pen(QColor(60, 60, 60, 160)); + pen.setStyle(Qt::DashLine); + painter->setPen(pen); + for (const Line &line : lines) { + const qint64 t = line.time.toMSecsSinceEpoch(); + if (t < t0 || t > t1) continue; + const double x = timeToX(t, t0, t1, plotRect); + painter->drawLine(QPointF(x, plotRect.top()), QPointF(x, plotRect.bottom())); + painter->drawText(QPointF(x + 4, plotRect.top() + 12), line.label); + } + } + + void drawTimeAxis(QPainter *painter, const QRect &plotRect, qint64 t0, qint64 t1) { + const int tickCount = 6; + painter->setPen(palette().color(QPalette::Text)); + for (int i = 0; i <= tickCount; ++i) { + const double frac = double(i) / tickCount; + const qint64 t = t0 + qint64(frac * (t1 - t0)); + const double x = plotRect.left() + frac * plotRect.width(); + painter->drawLine(QPointF(x, plotRect.bottom()), QPointF(x, plotRect.bottom() + 4)); + painter->drawLine(QPointF(x, plotRect.top()), QPointF(x, plotRect.top() - 4)); + QDateTime dt = QDateTime::fromMSecsSinceEpoch(t, Qt::UTC).toLocalTime(); + const QString label = dt.toString("HH:mm"); + painter->drawText(QPointF(x - 14, plotRect.bottom() + 18), label); + painter->drawText(QPointF(x - 14, plotRect.top() - 6), label); + } + } + + void drawIdleGaps(QPainter *painter, const QRect &plotRect, qint64 t0, qint64 t1) { + if (data_.idleIntervals.isEmpty()) return; + QColor gapColor(220, 50, 50, 40); + painter->setPen(Qt::NoPen); + painter->setBrush(gapColor); + for (const auto &interval : data_.idleIntervals) { + const qint64 s = interval.first.toMSecsSinceEpoch(); + const qint64 e = interval.second.toMSecsSinceEpoch(); + const qint64 gs = std::max(s, t0); + const qint64 ge = std::min(e, t1); + if (ge <= gs) continue; + const double x1 = timeToX(gs, t0, t1, plotRect); + const double x2 = timeToX(ge, t0, t1, plotRect); + painter->drawRect(QRectF(x1, plotRect.top(), x2 - x1, plotRect.height())); + } + } + + void drawExposureBar(QPainter *painter, const QRect &rowRect, const TimelineTarget &target, + qint64 t0, qint64 t1, bool selected, int colorIndex, bool observed) { + if (!observed) return; + QVector> segments = target.segments; + if (segments.isEmpty() && target.startUtc.isValid() && target.endUtc.isValid()) { + segments.append({target.startUtc, target.endUtc}); + } + if (segments.isEmpty()) return; + const QRect plotRect = rowRect; + QColor color = displayColorForTarget(target, colorIndex, observed); + color.setAlpha(selected ? 200 : 150); + painter->setPen(Qt::NoPen); + painter->setBrush(color); + for (const auto &segment : segments) { + const qint64 s = segment.first.toMSecsSinceEpoch(); + const qint64 e = segment.second.toMSecsSinceEpoch(); + if (e <= t0 || s >= t1) continue; + const double x1 = timeToX(std::max(s, t0), t0, t1, plotRect); + const double x2 = timeToX(std::min(e, t1), t0, t1, plotRect); + const double y = rowRect.center().y() - barHeight_ / 2.0; + QRectF bar(x1, y, x2 - x1, barHeight_); + painter->drawRoundedRect(bar, 3, 3); + } + } + + void drawAirmassCurve(QPainter *painter, const QRect &rowRect, const TimelineTarget &target, + qint64 t0, qint64 t1, double minVal, double maxVal, + bool selected, int colorIndex, bool observed) { + if (data_.timesUtc.isEmpty() || target.airmass.isEmpty()) return; + const int n = std::min(data_.timesUtc.size(), target.airmass.size()); + if (n <= 1) return; + QPainterPath path; + bool started = false; + for (int i = 0; i < n; ++i) { + const double am = target.airmass.at(i); + if (!std::isfinite(am) || am > maxVal || am < minVal) { + if (started) started = false; + continue; + } + const qint64 t = data_.timesUtc.at(i).toMSecsSinceEpoch(); + const double x = timeToX(t, t0, t1, rowRect); + const double frac = (am - minVal) / (maxVal - minVal); + const double clamped = std::min(1.0, std::max(0.0, frac)); + const double y = rowRect.top() + clamped * rowRect.height(); + if (!started) { + path.moveTo(x, y); + started = true; + } else { + path.lineTo(x, y); + } + } + if (path.isEmpty()) return; + + QColor lineColor = displayColorForTarget(target, colorIndex, observed); + if (!observed) lineColor = palette().color(QPalette::Disabled, QPalette::Text); + QPen pen(lineColor, selected ? 2.0 : 1.2); + pen.setStyle(Qt::DashLine); + pen.setCapStyle(Qt::RoundCap); + pen.setJoinStyle(Qt::RoundJoin); + painter->setPen(pen); + painter->setBrush(Qt::NoBrush); + painter->drawPath(path); + } + + void drawAirmassCurveLabels(QPainter *painter, const QRect &rowRect, const TimelineTarget &target, + qint64 t0, qint64 t1, double minVal, double maxVal) { + if (data_.timesUtc.isEmpty() || target.airmass.isEmpty()) return; + const int n = std::min(data_.timesUtc.size(), target.airmass.size()); + if (n <= 0) return; + + QVector visibleIdx; + visibleIdx.reserve(n); + for (int i = 0; i < n; ++i) { + const double am = target.airmass.at(i); + if (!std::isfinite(am) || am > maxVal || am < minVal) continue; + visibleIdx.append(i); + } + if (visibleIdx.isEmpty()) return; + + int labelCount = std::min(10, static_cast(visibleIdx.size())); + QFont font = painter->font(); + font.setPointSize(std::max(8, font.pointSize() - 1)); + painter->setFont(font); + QColor labelColor = palette().color(QPalette::Text); + labelColor.setAlpha(170); + painter->setPen(labelColor); + QFontMetrics fm(font); + + // Determine consistent placement (above or below) based on peak location. + double peak = -1.0; + int peakIdx = -1; + for (int i = 0; i < visibleIdx.size(); ++i) { + const double am = target.airmass.at(visibleIdx.at(i)); + if (am > peak) { + peak = am; + peakIdx = visibleIdx.at(i); + } + } + const bool placeAbove = (peakIdx >= 0) ? (peakIdx < n / 2) : true; + + const int labelHeight = fm.height(); + const int minSpacing = std::max(6, labelHeight + 2); + if (labelCount > 1) { + const int labelWidth = fm.horizontalAdvance(QString::number(peak > 0 ? peak : 1.0, 'f', 1)); + const int desiredSpacing = labelWidth + 6; + const int maxLabelsByWidth = std::max(1, rowRect.width() / std::max(1, desiredSpacing)); + labelCount = std::min(labelCount, maxLabelsByWidth); + } + + double lastX = -1e9; + for (int k = 0; k < labelCount; ++k) { + const int idx = visibleIdx.at((k * (visibleIdx.size() - 1)) / std::max(1, labelCount - 1)); + const double am = target.airmass.at(idx); + const qint64 t = data_.timesUtc.at(idx).toMSecsSinceEpoch(); + const double x = timeToX(t, t0, t1, rowRect); + const double frac = (am - minVal) / (maxVal - minVal); + const double clamped = std::min(1.0, std::max(0.0, frac)); + const double y = rowRect.top() + clamped * rowRect.height(); + const QString label = QString::number(am, 'f', 1); + const int labelWidth = fm.horizontalAdvance(label); + if (x - lastX < minSpacing) continue; + lastX = x; + int lx = int(x - labelWidth / 2); + if (lx < rowRect.left() + 2) lx = rowRect.left() + 2; + if (lx + labelWidth > rowRect.right() - 2) lx = rowRect.right() - 2 - labelWidth; + int ly = placeAbove ? int(y - labelHeight - 2) : int(y + 2); + if (placeAbove && ly < rowRect.top() + 2) ly = int(y + 2); + if (!placeAbove && ly + labelHeight > rowRect.bottom() - 2) ly = int(y - labelHeight - 2); + painter->drawText(QRect(lx, ly, labelWidth, labelHeight), + Qt::AlignCenter, label); + } + } + + void drawRowSeparator(QPainter *painter, const QRect &plotRect, const QRect &rowRect, int index) { + if (index >= data_.targets.size() - 1) return; + QColor lineColor(90, 90, 90); + lineColor.setAlpha(35); + QPen pen(lineColor, 1.0); + pen.setStyle(Qt::DashLine); + painter->setPen(pen); + const int y = rowRect.bottom(); + painter->drawLine(plotRect.left(), y, plotRect.right(), y); + } + + void drawRowSeparators(QPainter *painter, const QRect &plotRect) { + for (int i = 0; i < data_.targets.size(); ++i) { + const QRect rowRect = rowArea(plotRect, i); + drawRowSeparator(painter, plotRect, rowRect, i); + } + } + + void drawDropIndicator(QPainter *painter, const QRect &plotRect) { + if (!dragging_ || dragHoverIndex_ < 0 || dragHoverIndex_ >= data_.targets.size()) return; + const QRect rowRect = rowArea(plotRect, dragHoverIndex_); + QColor lineColor(90, 160, 255); + QPen pen(lineColor, 2.0); + painter->setPen(pen); + const int y = dragInsertAbove_ ? rowRect.top() : rowRect.bottom(); + painter->drawLine(plotRect.left(), y, plotRect.right(), y); + } +}; + +class TimelinePanel : public QWidget { + Q_OBJECT +public: + explicit TimelinePanel(QWidget *parent = nullptr) : QWidget(parent) { + QVBoxLayout *layout = new QVBoxLayout(this); + scroll_ = new QScrollArea(this); + scroll_->setWidgetResizable(true); + canvas_ = new TimelineCanvas(this); + scroll_->setWidget(canvas_); + layout->addWidget(scroll_, 1); + + connect(canvas_, &TimelineCanvas::targetSelected, this, &TimelinePanel::targetSelected); + connect(canvas_, &TimelineCanvas::targetReorderRequested, this, &TimelinePanel::targetReorderRequested); + connect(canvas_, &TimelineCanvas::flagClicked, this, &TimelinePanel::flagClicked); + connect(canvas_, &TimelineCanvas::contextMenuRequested, this, &TimelinePanel::contextMenuRequested); + connect(canvas_, &TimelineCanvas::exptimeEditRequested, this, &TimelinePanel::exptimeEditRequested); + } + + void setData(const TimelineData &data) { canvas_->setData(data); } + void clear() { canvas_->clear(); } + void setSelectedObsId(const QString &obsId) { canvas_->setSelectedObsId(obsId); } + +signals: + void targetSelected(const QString &obsId); + void targetReorderRequested(const QString &fromObsId, const QString &toObsId); + void flagClicked(const QString &obsId, const QString &flagText); + void contextMenuRequested(const QString &obsId, const QPoint &globalPos); + void exptimeEditRequested(const QString &obsId); + +private: + QScrollArea *scroll_ = nullptr; + TimelineCanvas *canvas_ = nullptr; +}; + +class MainWindow : public QMainWindow { + Q_OBJECT +public: + MainWindow(QWidget *parent = nullptr) : QMainWindow(parent) { + setWindowTitle("NGPS Target Set Editor"); + QWidget *central = new QWidget(this); + QVBoxLayout *layout = new QVBoxLayout(central); + + QHBoxLayout *topBar = new QHBoxLayout(); + seqStart_ = new QPushButton("Seq Start", central); + seqAbort_ = new QPushButton("Seq Abort", central); + QPushButton *activateSetButton = new QPushButton("Activate target set", central); + QPushButton *importCsvButton = new QPushButton("Import CSV", central); + QPushButton *deleteSetButton = new QPushButton("Delete target set", central); + runOtm_ = new QPushButton("Run OTM", central); + showOtmLog_ = new QPushButton("Show OTM log", central); + QLabel *otmStartLabel = new QLabel("OTM Start UTC:", central); + otmStartEdit_ = new QLineEdit(central); + otmUseNow_ = new QCheckBox("Use current time", central); + QLabel *doTypeLabel = new QLabel("Seq Do:", central); + QToolButton *doAllButton = new QToolButton(central); + QToolButton *doOneButton = new QToolButton(central); + QLabel *acqModeLabel = new QLabel("Acq Mode:", central); + QToolButton *acqMode1Button = new QToolButton(central); + QToolButton *acqMode2Button = new QToolButton(central); + QToolButton *acqMode3Button = new QToolButton(central); + connStatus_ = new QLabel("Not connected", central); + + doAllButton->setText("All"); + doAllButton->setCheckable(true); + doOneButton->setText("One"); + doOneButton->setCheckable(true); + doAllButton->setChecked(true); + + acqMode1Button->setText("1"); + acqMode1Button->setCheckable(true); + acqMode2Button->setText("2"); + acqMode2Button->setCheckable(true); + acqMode3Button->setText("3"); + acqMode3Button->setCheckable(true); + acqMode1Button->setChecked(true); + + QButtonGroup *doTypeGroup = new QButtonGroup(this); + doTypeGroup->setExclusive(true); + doTypeGroup->addButton(doAllButton, 0); + doTypeGroup->addButton(doOneButton, 1); + + QButtonGroup *acqModeGroup = new QButtonGroup(this); + acqModeGroup->setExclusive(true); + acqModeGroup->addButton(acqMode1Button, 1); + acqModeGroup->addButton(acqMode2Button, 2); + acqModeGroup->addButton(acqMode3Button, 3); + + topBar->addWidget(seqStart_); + topBar->addWidget(seqAbort_); + topBar->addWidget(activateSetButton); + topBar->addWidget(importCsvButton); + topBar->addWidget(deleteSetButton); + topBar->addWidget(runOtm_); + topBar->addWidget(showOtmLog_); + topBar->addSpacing(12); + topBar->addWidget(doTypeLabel); + topBar->addWidget(doAllButton); + topBar->addWidget(doOneButton); + topBar->addSpacing(12); + topBar->addWidget(acqModeLabel); + topBar->addWidget(acqMode1Button); + topBar->addWidget(acqMode2Button); + topBar->addWidget(acqMode3Button); + topBar->addStretch(); + topBar->addWidget(connStatus_); + layout->addLayout(topBar); + + QHBoxLayout *otmBar = new QHBoxLayout(); + otmBar->addWidget(otmStartLabel); + otmBar->addWidget(otmStartEdit_); + otmBar->addWidget(otmUseNow_); + otmBar->addStretch(); + layout->addLayout(otmBar); + + otmStartEdit_->setFixedWidth(90); + otmStartEdit_->setPlaceholderText("YYYY-MM-DDTHH:MM:SS.sss"); + + tabs_ = new QTabWidget(central); + setsPanel_ = new TablePanel("Target Sets", tabs_); + setTargetsPanel_ = new TablePanel("Targets (Set View)", tabs_); + timelinePanel_ = new TimelinePanel(tabs_); + + setTargetsPanel_->setSearchColumn("NAME"); + setTargetsPanel_->setOrderByColumn("OBS_ORDER"); + setTargetsPanel_->setSortingEnabled(false); + setTargetsPanel_->setAllowReorder(true); + setTargetsPanel_->setAllowDelete(true); + setTargetsPanel_->setAllowColumnHeaderBulkEdit(true); + setTargetsPanel_->setRowNormalizer(normalizeTargetRow); + setTargetsPanel_->setGroupingEnabled(true); + setTargetsPanel_->setQuickAddEnabled(true); + setTargetsPanel_->setQuickAddInsertAtTop(true); + setTargetsPanel_->setHiddenColumns({"OBSERVATION_ID", "SET_ID", "OBS_ORDER", + "TARGET_NUMBER", "SEQUENCE_NUMBER", "SLITOFFSET", + "OBSMODE"}); + setTargetsPanel_->setColumnAfterRules({{"NEXP", "EXPTIME"}, + {"EXPTIME", "OTMexpt"}, + {"OTMEXPT", "SLITWIDTH"}, + {"SLITWIDTH", "OTMslitwidth"}, + {"AIRMASS_MAX", "MAGNITUDE"}, + {"MAGNITUDE", "MAGFILTER"}}); + setTargetsPanel_->setQuickAddBuilder([this](QVariantMap &values, QSet &nullColumns, + QString *error) -> bool { + Q_UNUSED(nullColumns); + if (!setTargetsPanel_) { + if (error) *error = "Target list not ready."; + return false; + } + const QString setIdStr = setTargetsPanel_->fixedFilterValue(); + bool okSet = false; + const int setId = setIdStr.toInt(&okSet); + if (!okSet || setId <= 0) { + if (error) *error = "Select a target set before adding targets."; + return false; + } + + QSet cols; + for (const ColumnMeta &meta : setTargetsPanel_->columns()) { + cols.insert(meta.name.toUpper()); + } + auto hasCol = [&](const QString &name) { return cols.contains(name.toUpper()); }; + auto setVal = [&](const QString &name, const QVariant &val) { + if (hasCol(name)) values.insert(name, val); + }; + + int nameSeq = 1; + int insertOrder = 1; + QString err; + if (hasCol("OBS_ORDER")) { + if (!dbClient_.nextObsOrderForSet(config_.tableTargets, setId, &nameSeq, &err)) { + nameSeq = 1; + } + } + + if (insertOrder < 1) insertOrder = 1; + const QString name = QString("NewTarget %1").arg(nameSeq); + setVal("SET_ID", setId); + setVal("OBS_ORDER", insertOrder); + setVal("TARGET_NUMBER", 1); + setVal("SEQUENCE_NUMBER", 1); + setVal("STATE", kDefaultTargetState); + setVal("NAME", name); + setVal("RA", "0.0"); + setVal("DECL", "0.0"); + setVal("OFFSET_RA", "0.0"); + setVal("OFFSET_DEC", "0.0"); + setVal("DRA", "0.0"); + setVal("DDEC", "0.0"); + setVal("SLITANGLE", kDefaultSlitangle); + setVal("SLITWIDTH", kDefaultSlitwidth); + setVal("EXPTIME", kDefaultExptime); + setVal("NEXP", "1"); + setVal("POINTMODE", kDefaultPointmode); + setVal("CCDMODE", kDefaultCcdmode); + setVal("AIRMASS_MAX", formatNumber(kDefaultAirmassMax)); + setVal("BINSPAT", QString::number(kDefaultBin)); + setVal("BINSPECT", QString::number(kDefaultBin)); + setVal("CHANNEL", kDefaultChannel); + setVal("MAGNITUDE", formatNumber(kDefaultMagnitude)); + setVal("MAGSYSTEM", kDefaultMagsystem); + setVal("MAGFILTER", kDefaultMagfilter); + setVal("NOTBEFORE", kDefaultNotBefore); + setVal("SRCMODEL", normalizeSrcmodelValue(QString())); + + double low = 0.0; + double high = 0.0; + const auto def = defaultWrangeForChannel(kDefaultChannel); + low = def.first; + high = def.second; + setVal("WRANGE_LOW", formatNumber(low)); + setVal("WRANGE_HIGH", formatNumber(high)); + + double exptimeNumeric = 0.0; + if (extractSetNumeric(kDefaultExptime, &exptimeNumeric)) { + setVal("OTMexpt", formatNumber(exptimeNumeric)); + } + setVal("OTMslitwidth", formatNumber(kDefaultOtmSlitwidth)); + + return true; + }); + + tabs_->addTab(setsPanel_, "Target Sets"); + tabs_->addTab(setTargetsPanel_, "Targets (Set View)"); + tabs_->addTab(timelinePanel_, "Timeline"); + layout->addWidget(tabs_, 5); + + setCentralWidget(central); + + connect(seqStart_, &QPushButton::clicked, this, &MainWindow::seqStart); + connect(seqAbort_, &QPushButton::clicked, this, &MainWindow::seqAbort); + connect(activateSetButton, &QPushButton::clicked, this, &MainWindow::activateSelectedSet); + connect(importCsvButton, &QPushButton::clicked, this, &MainWindow::importTargetListCsv); + connect(deleteSetButton, &QPushButton::clicked, this, &MainWindow::deleteSelectedSet); + connect(runOtm_, &QPushButton::clicked, this, &MainWindow::runOtm); + connect(showOtmLog_, &QPushButton::clicked, this, &MainWindow::showOtmLog); + connect(otmUseNow_, &QCheckBox::toggled, this, &MainWindow::handleOtmUseNowToggle); + connect(otmStartEdit_, &QLineEdit::editingFinished, this, [this]() { + saveOtmStart(); + scheduleAutoOtmRun(); + }); + connect(setsPanel_, &TablePanel::selectionChanged, this, &MainWindow::updateSetViewFromSelection); + connect(setTargetsPanel_, &TablePanel::dataMutated, this, &MainWindow::scheduleAutoOtmRun); + connect(doTypeGroup, QOverload::of(&QButtonGroup::idClicked), this, + [this](int id) { runSeqCommand({"do", id == 0 ? "all" : "one"}); }); + connect(acqModeGroup, QOverload::of(&QButtonGroup::idClicked), this, + [this](int id) { runSeqCommand({"acqmode", QString::number(id)}); }); + + otmAutoTimer_ = new QTimer(this); + otmAutoTimer_->setSingleShot(true); + connect(otmAutoTimer_, &QTimer::timeout, this, &MainWindow::runOtmAuto); + + connect(timelinePanel_, &TimelinePanel::targetSelected, this, [this](const QString &obsId) { + if (!obsId.isEmpty()) { + setTargetsPanel_->selectRowByColumnValue("OBSERVATION_ID", obsId); + } + }); + connect(timelinePanel_, &TimelinePanel::targetReorderRequested, this, + [this](const QString &fromObsId, const QString &toObsId) { + handleTimelineReorder(fromObsId, toObsId); + }); + connect(timelinePanel_, &TimelinePanel::flagClicked, this, + [this](const QString &obsId, const QString &flagText) { + showOtmFlagDetails(obsId, flagText); + }); + connect(timelinePanel_, &TimelinePanel::contextMenuRequested, this, + [this](const QString &obsId, const QPoint &globalPos) { + if (!setTargetsPanel_) return; + setTargetsPanel_->showContextMenuForObsId(obsId, globalPos); + }); + connect(timelinePanel_, &TimelinePanel::exptimeEditRequested, this, + [this](const QString &obsId) { editExptimeForObsId(obsId); }); + + connect(setTargetsPanel_, &TablePanel::selectionChanged, this, [this]() { + const QVariantMap values = setTargetsPanel_->currentRowValues(); + const QString obsId = values.value("OBSERVATION_ID").toString(); + if (!obsId.isEmpty() && timelinePanel_) { + timelinePanel_->setSelectedObsId(obsId); + } + }); + + connectFromConfig(); + } + +protected: + void closeEvent(QCloseEvent *event) override { + closing_ = true; + if (otmAutoTimer_) otmAutoTimer_->stop(); + if (setsPanel_) setsPanel_->persistHeaderState(); + if (setTargetsPanel_) setTargetsPanel_->persistHeaderState(); + for (QProcess *proc : findChildren()) { + proc->disconnect(); + if (proc->state() != QProcess::NotRunning) { + proc->kill(); + proc->waitForFinished(200); + } + } + QMainWindow::closeEvent(event); + } + +private slots: + void connectFromConfig() { + const QString cfgPath = detectDefaultConfigPath(); + if (cfgPath.isEmpty()) { + showWarning(this, "Config", "sequencerd.cfg not found."); + return; + } + configPath_ = cfgPath; + config_ = loadConfigFile(cfgPath); + if (!config_.isComplete()) { + showWarning(this, "Config", "Config file is missing DB settings."); + return; + } + openDatabase(); + } + + void seqStart() { seqStartWithStartupCheck(); } + void seqAbort() { runSeqCommand({"abort"}); } + void activateSelectedSet() { + const QVariantMap values = setsPanel_->currentRowValues(); + if (values.isEmpty()) { + showInfo(this, "Target Sets", "Select a target set first."); + return; + } + const QVariant setName = values.value("SET_NAME"); + const QString setNameText = setName.toString().trimmed(); + if (!setName.isValid() || setNameText.isEmpty()) { + showWarning(this, "Target Sets", "SET_NAME not found."); + return; + } + runSeqCommand({"targetset", setNameText}); + } + + void importTargetListCsv() { + if (!dbClient_.isOpen()) { + showWarning(this, "Import CSV", "Not connected to database."); + return; + } + const QString filePath = QFileDialog::getOpenFileName( + this, "Import Target List CSV", QDir::currentPath(), "CSV Files (*.csv)"); + if (filePath.isEmpty()) return; + + const QString defaultSetName = QFileInfo(filePath).baseName(); + bool ok = false; + QString setName = QInputDialog::getText( + this, "New Target Set", "Target set name:", QLineEdit::Normal, defaultSetName, &ok); + if (!ok) return; + setName = setName.trimmed(); + if (setName.isEmpty()) { + showWarning(this, "Import CSV", "Target set name is required."); + return; + } + + int setId = -1; + QString error; + if (!createTargetSet(setName, &setId, &error)) { + showWarning(this, "Import CSV", error.isEmpty() ? "Failed to create target set." : error); + return; + } + + QList targetColumns; + if (!dbClient_.loadColumns(config_.tableTargets, targetColumns, &error)) { + showWarning(this, "Import CSV", error.isEmpty() ? "Failed to load target columns." : error); + return; + } + + QSet targetColumnNames; + for (const ColumnMeta &meta : targetColumns) { + targetColumnNames.insert(meta.name.toUpper()); + } + + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + showWarning(this, "Import CSV", "Unable to read CSV file."); + return; + } + + QTextStream in(&file); + QString headerLine; + while (!in.atEnd()) { + headerLine = in.readLine(); + if (!headerLine.trimmed().isEmpty()) break; + } + if (headerLine.trimmed().isEmpty()) { + showWarning(this, "Import CSV", "CSV file is empty."); + return; + } + + const QStringList headerFields = parseCsvLine(headerLine); + QHash headerMap; + for (int i = 0; i < headerFields.size(); ++i) { + headerMap.insert(headerFields[i].trimmed().toUpper(), i); + } + + auto getField = [&](const QString &name, const QStringList &fields) -> QString { + const int idx = headerMap.value(name.toUpper(), -1); + if (idx < 0 || idx >= fields.size()) return QString(); + return fields.at(idx).trimmed(); + }; + + int obsOrder = 1; + int inserted = 0; + int rowIndex = 0; + QStringList warnings; + + while (!in.atEnd()) { + const QString line = in.readLine(); + if (line.trimmed().isEmpty()) continue; + ++rowIndex; + const QStringList fields = parseCsvLine(line); + if (fields.size() == 1 && fields.at(0).trimmed().isEmpty()) continue; + + QVariantMap values; + QSet nullColumns; + + const QString name = getField("NAME", fields); + if (name.trimmed().isEmpty()) { + warnings << QString("Skipped row %1: missing NAME").arg(rowIndex); + continue; + } + const bool isCalib = name.toUpper().startsWith("CAL_"); + + if (targetColumnNames.contains("SET_ID")) values.insert("SET_ID", setId); + if (targetColumnNames.contains("OBS_ORDER")) values.insert("OBS_ORDER", obsOrder); + if (targetColumnNames.contains("TARGET_NUMBER")) values.insert("TARGET_NUMBER", 1); + if (targetColumnNames.contains("SEQUENCE_NUMBER")) values.insert("SEQUENCE_NUMBER", 1); + if (targetColumnNames.contains("STATE")) values.insert("STATE", kDefaultTargetState); + if (targetColumnNames.contains("NAME")) values.insert("NAME", name); + + const QString ra = getField("RA", fields); + const QString dec = getField("DECL", fields); + if (!isCalib && (ra.isEmpty() || dec.isEmpty())) { + warnings << QString("Skipped row %1 (%2): missing RA/DECL").arg(rowIndex).arg(name); + continue; + } + if (targetColumnNames.contains("RA") && !ra.isEmpty()) values.insert("RA", ra); + if (targetColumnNames.contains("DECL") && !dec.isEmpty()) values.insert("DECL", dec); + + const QString offsetRa = getField("OFFSET_RA", fields); + const QString offsetDec = getField("OFFSET_DEC", fields); + if (targetColumnNames.contains("OFFSET_RA") && !offsetRa.isEmpty()) values.insert("OFFSET_RA", offsetRa); + if (targetColumnNames.contains("OFFSET_DEC") && !offsetDec.isEmpty()) values.insert("OFFSET_DEC", offsetDec); + + const QString slitangle = getField("SLITANGLE", fields); + const QString slitwidth = getField("SLITWIDTH", fields); + const QString exptime = getField("EXPTIME", fields); + if (targetColumnNames.contains("SLITANGLE")) values.insert("SLITANGLE", slitangle); + if (targetColumnNames.contains("SLITWIDTH")) values.insert("SLITWIDTH", slitwidth); + if (targetColumnNames.contains("EXPTIME")) values.insert("EXPTIME", exptime); + + const QString binspect = getField("BINSPECT", fields); + const QString binspat = getField("BINSPAT", fields); + if (targetColumnNames.contains("BINSPECT")) values.insert("BINSPECT", binspect); + if (targetColumnNames.contains("BINSPAT")) values.insert("BINSPAT", binspat); + + const QString airmassMax = getField("AIRMASS_MAX", fields); + if (targetColumnNames.contains("AIRMASS_MAX")) values.insert("AIRMASS_MAX", airmassMax); + + const QString wrangeLow = getField("WRANGE_LOW", fields); + const QString wrangeHigh = getField("WRANGE_HIGH", fields); + if (targetColumnNames.contains("WRANGE_LOW")) values.insert("WRANGE_LOW", wrangeLow); + if (targetColumnNames.contains("WRANGE_HIGH")) values.insert("WRANGE_HIGH", wrangeHigh); + + const QString channel = getField("CHANNEL", fields); + if (targetColumnNames.contains("CHANNEL")) values.insert("CHANNEL", channel); + + const QString magnitude = getField("MAGNITUDE", fields); + const QString magfilter = getField("MAGFILTER", fields); + const QString magsystem = getField("MAGSYSTEM", fields); + if (targetColumnNames.contains("MAGNITUDE")) values.insert("MAGNITUDE", magnitude); + if (targetColumnNames.contains("MAGFILTER")) values.insert("MAGFILTER", magfilter); + if (targetColumnNames.contains("MAGSYSTEM")) values.insert("MAGSYSTEM", magsystem); + + if (targetColumnNames.contains("POINTMODE")) values.insert("POINTMODE", getField("POINTMODE", fields)); + if (targetColumnNames.contains("CCDMODE")) values.insert("CCDMODE", getField("CCDMODE", fields)); + if (targetColumnNames.contains("NOTBEFORE")) values.insert("NOTBEFORE", getField("NOTBEFORE", fields)); + + QString comment = getField("COMMENT", fields); + const QString priority = getField("PRIORITY", fields); + if (!priority.trimmed().isEmpty()) { + if (!comment.trimmed().isEmpty()) comment += " "; + comment += QString("PRIORITY=%1").arg(priority.trimmed()); + } + if (targetColumnNames.contains("COMMENT") && !comment.isEmpty()) values.insert("COMMENT", comment); + + if (targetColumnNames.contains("OTMSLITWIDTH")) values.insert("OTMslitwidth", QString()); + if (targetColumnNames.contains("OTMEXPT")) values.insert("OTMexpt", QString()); + + normalizeTargetRow(values, nullColumns); + + if (targetColumnNames.contains("OTMSLITWIDTH")) { + if (values.value("OTMslitwidth").toString().trimmed().isEmpty()) { + values.insert("OTMslitwidth", formatNumber(kDefaultOtmSlitwidth)); + } + } + + for (const ColumnMeta &meta : targetColumns) { + if (values.contains(meta.name)) { + const QString text = values.value(meta.name).toString().trimmed(); + if (text.isEmpty() && meta.nullable) { + nullColumns.insert(meta.name); + } + } else if (meta.nullable) { + nullColumns.insert(meta.name); + } + } + + QString rowError; + if (!dbClient_.insertRecord(config_.tableTargets, targetColumns, values, nullColumns, &rowError)) { + warnings << QString("Row %1: %2").arg(rowIndex).arg(rowError.isEmpty() ? "Insert failed" : rowError); + continue; + } + ++inserted; + ++obsOrder; + } + + if (inserted > 0 && targetColumnNames.contains("SET_ID")) { + QVariantMap keyValues; + keyValues.insert("SET_ID", setId); + QVariantMap updates; + updates.insert("NUM_OBSERVATIONS", inserted); + dbClient_.updateColumnsByKey(config_.tableSets, updates, keyValues, nullptr); + } + + setsPanel_->refresh(); + setsPanel_->selectRowByColumnValue("SET_ID", setId); + updateSetViewFromSelection(); + + QString summary = QString("Imported %1 targets into set \"%2\".").arg(inserted).arg(setName); + if (!warnings.isEmpty()) { + summary += "\n\nWarnings:\n" + warnings.join("\n"); + } + showInfo(this, "Import CSV", summary); + scheduleAutoOtmRun(); + } + + void deleteSelectedSet() { + if (!dbClient_.isOpen()) { + showWarning(this, "Delete Target Set", "Not connected to database."); + return; + } + const QVariantMap values = setsPanel_->currentRowValues(); + if (values.isEmpty()) { + showInfo(this, "Delete Target Set", "Select a target set first."); + return; + } + const QVariant setId = values.value("SET_ID"); + const QString setName = values.value("SET_NAME").toString(); + if (!setId.isValid()) { + showWarning(this, "Delete Target Set", "SET_ID not found."); + return; + } + + const QString prompt = QString("Delete target set \"%1\" and all targets in it?") + .arg(setName.isEmpty() ? setId.toString() : setName); + if (QMessageBox::question(this, "Delete Target Set", prompt, + QMessageBox::Yes | QMessageBox::No) != QMessageBox::Yes) { + return; + } + + QString error; + if (!dbClient_.deleteRecordsByColumn(config_.tableTargets, "SET_ID", setId, &error)) { + showWarning(this, "Delete Target Set", error.isEmpty() ? "Failed to delete targets." : error); + return; + } + QVariantMap keyValues; + keyValues.insert("SET_ID", setId); + if (!dbClient_.deleteRecordByKey(config_.tableSets, keyValues, &error)) { + showWarning(this, "Delete Target Set", error.isEmpty() ? "Failed to delete target set." : error); + return; + } + setsPanel_->refresh(); + setTargetsPanel_->clearFixedFilter(); + setTargetsPanel_->refresh(); + if (timelinePanel_) timelinePanel_->clear(); + } + + void showOtmLog() { + if (lastOtmLog_.trimmed().isEmpty()) { + showInfo(this, "OTM Log", "No OTM output captured yet."); + return; + } + showInfo(this, "OTM Log", lastOtmLog_); + } + + void showOtmFlagDetails(const QString &obsId, const QString &flagText) { + if (flagText.trimmed().isEmpty()) return; + QString detail = explainOtmFlags(flagText); + if (detail.isEmpty()) { + detail = QString("Flags: %1").arg(flagText.trimmed()); + } else { + detail = QString("Flags: %1\n\n%2").arg(flagText.trimmed(), detail); + } + if (!obsId.trimmed().isEmpty()) { + detail.prepend(QString("Target: %1\n").arg(obsId.trimmed())); + } + showInfo(this, "OTM Flag Details", detail); + } + + void editExptimeForObsId(const QString &obsId) { + if (obsId.trimmed().isEmpty()) return; + if (!dbClient_.isOpen()) { + showWarning(this, "Edit EXPTIME", "Not connected to database."); + return; + } + if (!setTargetsPanel_) return; + + const bool hasNexp = setTargetsPanel_->hasColumn("NEXP"); + QVariant currentVal = setTargetsPanel_->valueForColumnInRow("OBSERVATION_ID", obsId, "EXPTIME"); + QString currentText = currentVal.toString().trimmed(); + if (currentText.isEmpty()) currentText = kDefaultExptime; + + int currentNexp = 1; + if (hasNexp) { + QVariant nexpVal = setTargetsPanel_->valueForColumnInRow("OBSERVATION_ID", obsId, "NEXP"); + int parsed = 0; + if (parseInt(nexpVal.toString(), &parsed) && parsed > 0) { + currentNexp = parsed; + } + } + + QDialog dialog(this); + dialog.setWindowTitle("Edit Exposure"); + QVBoxLayout *layout = new QVBoxLayout(&dialog); + QFormLayout *form = new QFormLayout(); + QLineEdit *exptimeEdit = new QLineEdit(currentText, &dialog); + form->addRow("EXPTIME (SET or SNR ):", exptimeEdit); + QSpinBox *nexpSpin = nullptr; + if (hasNexp) { + nexpSpin = new QSpinBox(&dialog); + nexpSpin->setRange(1, 9999); + nexpSpin->setValue(currentNexp); + form->addRow("NEXP:", nexpSpin); + } + layout->addLayout(form); + QDialogButtonBox *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dialog); + connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + layout->addWidget(buttons); + + if (dialog.exec() != QDialog::Accepted) return; + QString newText = exptimeEdit->text().trimmed(); + if (newText.isEmpty()) return; + int newNexp = currentNexp; + if (hasNexp && nexpSpin) { + newNexp = std::max(1, nexpSpin->value()); + } + + QStringList targetObsIds; + QHash groups = setTargetsPanel_->groupMembersByHeaderObsId(); + if (groups.contains(obsId)) { + targetObsIds = groups.value(obsId); + } else { + for (auto it = groups.begin(); it != groups.end(); ++it) { + if (it.value().contains(obsId)) { + targetObsIds = it.value(); + break; + } + } + } + if (targetObsIds.isEmpty()) targetObsIds << obsId; + + QStringList errors; + for (const QString &memberObsId : targetObsIds) { + QVariant nameVal = setTargetsPanel_->valueForColumnInRow("OBSERVATION_ID", memberObsId, "NAME"); + const QString name = nameVal.toString().trimmed(); + const bool isCalib = name.toUpper().startsWith("CAL_"); + const QString normalized = normalizeExptimeValue(newText, isCalib); + QVariantMap updates; + updates.insert("EXPTIME", normalized); + if (hasNexp) { + updates.insert("NEXP", QString::number(newNexp)); + } + QVariantMap keyValues; + keyValues.insert("OBSERVATION_ID", memberObsId); + QString error; + if (!dbClient_.updateColumnsByKey(config_.tableTargets, updates, keyValues, &error)) { + errors << QString("%1: %2").arg(memberObsId, error.isEmpty() ? "Update failed" : error); + } + } + + if (!errors.isEmpty()) { + showWarning(this, "Edit EXPTIME", errors.join("\n")); + } else { + setTargetsPanel_->refresh(); + scheduleAutoOtmRun(); + } + } + + void scheduleAutoOtmRun() { + if (closing_) return; + if (!dbClient_.isOpen()) return; + if (otmRunning_) { + otmAutoPending_ = true; + return; + } + if (!otmAutoTimer_) return; + otmAutoTimer_->start(500); + } + + void runOtm() { runOtmInternal(true); } + + void runOtmAuto() { runOtmInternal(false); } + + void runOtmInternal(bool showDialog) { + if (closing_) return; + const bool quiet = !showDialog; + if (!dbClient_.isOpen()) { + if (!quiet) showWarning(this, "Run OTM", "Not connected to database."); + return; + } + + const QVariantMap setValues = setsPanel_->currentRowValues(); + if (setValues.isEmpty()) { + if (!quiet) showInfo(this, "Run OTM", "Select a target set first."); + return; + } + + const QVariant setIdValue = setValues.value("SET_ID"); + bool ok = false; + const int setId = setIdValue.toInt(&ok); + if (!ok) { + if (!quiet) showWarning(this, "Run OTM", "SET_ID not found."); + return; + } + + if (ngpsRoot_.isEmpty()) { + if (!quiet) showWarning(this, "Run OTM", "NGPS root not detected."); + return; + } + + const QString scriptPath = QDir(ngpsRoot_).filePath("Python/OTM/OTM.py"); + if (!QFile::exists(scriptPath)) { + if (!quiet) showWarning(this, "Run OTM", QString("OTM script not found: %1").arg(scriptPath)); + return; + } + + if (otmRunning_) { + otmAutoPending_ = true; + return; + } + + OtmSettings settings = loadOtmSettings(); + if (showDialog) { + OtmSettingsDialog dialog(settings, this); + if (dialog.exec() != QDialog::Accepted) { + return; + } + settings = dialog.settings(); + saveOtmSettings(settings); + } + + QString startUtc = otmStartEdit_ ? otmStartEdit_->text().trimmed() : QString(); + if (otmUseNow_ && otmUseNow_->isChecked()) { + startUtc = currentUtcString(); + if (otmStartEdit_) { + otmStartEdit_->setText(startUtc); + } + } + if (startUtc.isEmpty()) { + startUtc = estimateTwilightUtc(); + if (startUtc.isEmpty()) startUtc = currentUtcString(); + if (otmStartEdit_) { + otmStartEdit_->setText(startUtc); + } + } + startUtc = normalizeOtmStartText(startUtc, quiet); + if (otmStartEdit_) saveOtmStart(); + + QList targetColumns; + QString error; + if (!dbClient_.loadColumns(config_.tableTargets, targetColumns, &error)) { + if (!quiet) showWarning(this, "Run OTM", error.isEmpty() ? "Failed to load target columns." : error); + return; + } + + QVector> rows; + if (!dbClient_.fetchRows(config_.tableTargets, targetColumns, + "SET_ID", QString::number(setId), + "", "", "OBS_ORDER", + rows, &error)) { + if (!quiet) showWarning(this, "Run OTM", error.isEmpty() ? "Failed to load targets." : error); + return; + } + + if (rows.isEmpty()) { + if (!quiet) showInfo(this, "Run OTM", "No targets found in the selected set."); + return; + } + + QSet targetColumnNames; + for (int i = 0; i < targetColumns.size(); ++i) { + targetColumnNames.insert(targetColumns[i].name.toUpper()); + } + + QSet manualUngroupObsIds; + QHash panelGroups; + if (setTargetsPanel_) { + manualUngroupObsIds = setTargetsPanel_->ungroupedObsIds(); + panelGroups = setTargetsPanel_->groupMembersByHeaderObsId(); + } + QHash obsIdToHeader; + for (auto it = panelGroups.begin(); it != panelGroups.end(); ++it) { + const QString header = it.key(); + for (const QString &member : it.value()) { + if (!member.isEmpty()) obsIdToHeader.insert(member, header); + } + if (!header.isEmpty()) obsIdToHeader.insert(header, header); + } + + const bool includeSrcmodel = targetColumnNames.contains("SRCMODEL"); + const bool includeNexp = targetColumnNames.contains("NEXP"); + + QStringList header; + header << "OBSERVATION_ID" + << "name" << "RA" << "DECL" + << "slitangle" << "slitwidth"; + if (includeNexp) header << "NEXP"; + header << "exptime" + << "notbefore" << "pointmode" << "ccdmode" + << "airmass_max" << "binspat" << "binspect" + << "channel" << "wrange" << "mag" << "magsystem" << "magfilter"; + if (includeSrcmodel) header << "srcmodel"; + + struct RowRecord { + int rowIndex = 0; + QVariantMap values; + QSet nullColumns; + QString obsId; + QString name; + bool isCalib = false; + QString groupKey; + bool isScience = false; + }; + + struct GroupInfo { + QString scienceObsId; + QStringList members; + }; + + QStringList inputWarnings; + QStringList coordDiagnostics; + QHash oldSlitwidthByObsId; + QHash obsOrderByObsId; + QVector records; + QHash groups; + + int rowIndex = 0; + for (const QVector &row : rows) { + ++rowIndex; + QVariantMap values; + QSet nullColumns; + for (int i = 0; i < targetColumns.size(); ++i) { + if (i >= row.size()) continue; + const QVariant value = row.at(i); + if (value.isValid() && !value.isNull()) { + values.insert(targetColumns[i].name, value); + } else { + nullColumns.insert(targetColumns[i].name); + } + } + + normalizeTargetRow(values, nullColumns); + + const QString obsId = valueToStringCaseInsensitive(values, "OBSERVATION_ID"); + if (obsId.isEmpty()) { + inputWarnings << QString("Row %1: missing OBSERVATION_ID").arg(rowIndex); + continue; + } + + int obsOrder = 0; + bool orderOk = false; + obsOrder = valueToStringCaseInsensitive(values, "OBS_ORDER").toInt(&orderOk); + if (orderOk) { + obsOrderByObsId.insert(obsId, obsOrder); + } + + const QString name = valueToStringCaseInsensitive(values, "NAME"); + if (name.isEmpty()) { + inputWarnings << QString("Row %1: missing NAME").arg(rowIndex); + continue; + } + + const bool isCalib = name.toUpper().startsWith("CAL_"); + const QString ra = valueToStringCaseInsensitive(values, "RA"); + const QString dec = valueToStringCaseInsensitive(values, "DECL"); + if (!isCalib && (ra.isEmpty() || dec.isEmpty())) { + inputWarnings << QString("Row %1 (%2): missing RA/DECL").arg(rowIndex).arg(name); + continue; + } + + const QString groupKey = obsIdToHeader.value(obsId, obsId); + + { + double raDeg = 0.0; + double decDeg = 0.0; + if (computeScienceCoordDegreesProjected(values, &raDeg, &decDeg)) { + bool hasOffsetRa = false; + bool hasOffsetDec = false; + const double offsetRa = offsetArcsecFromValues(values, {"OFFSET_RA", "DRA"}, &hasOffsetRa); + const double offsetDec = offsetArcsecFromValues(values, {"OFFSET_DEC", "DDEC"}, &hasOffsetDec); + QString key = groupKey; + coordDiagnostics << QString("%1\t%2\tRA=%3\tDEC=%4\tDRA=%5\tDDEC=%6\tKEY=%7") + .arg(obsId, + name, + QString::number(raDeg, 'f', 6), + QString::number(decDeg, 'f', 6), + QString::number(offsetRa, 'f', 3), + QString::number(offsetDec, 'f', 3), + key); + } else { + coordDiagnostics << QString("%1\t%2\tRA/DEC parse failed").arg(obsId, name); + } + } + + const bool isScience = (obsId == groupKey); + + GroupInfo &group = groups[groupKey]; + group.members.append(obsId); + if (isScience && group.scienceObsId.isEmpty()) { + group.scienceObsId = obsId; + } + + RowRecord record; + record.rowIndex = rowIndex; + record.values = values; + record.nullColumns = nullColumns; + record.obsId = obsId; + record.name = name; + record.isCalib = isCalib; + record.groupKey = groupKey; + record.isScience = isScience; + records.append(record); + + const QVariant oldSlit = values.value(findKeyCaseInsensitive(values, "OTMslitwidth")); + if (oldSlit.isValid() && !oldSlit.isNull()) { + oldSlitwidthByObsId.insert(obsId, oldSlit); + } + } + + QHash membersByScienceObsId; + for (auto it = groups.begin(); it != groups.end(); ++it) { + if (it->members.isEmpty()) continue; + if (it->scienceObsId.isEmpty()) { + it->scienceObsId = it->members.first(); + inputWarnings << QString("Group %1: missing header row, using OBSERVATION_ID %2") + .arg(it.key(), it->scienceObsId); + } + membersByScienceObsId.insert(it->scienceObsId, it->members); + } + + QStringList lines; + lines << header.join(","); + for (const RowRecord &record : records) { + const GroupInfo group = groups.value(record.groupKey); + if (record.obsId != group.scienceObsId) { + continue; + } + + QVariantMap values = record.values; + QSet nullColumns = record.nullColumns; + const QString obsId = record.obsId; + const QString name = record.name; + const bool isCalib = record.isCalib; + + QString ra = valueToStringCaseInsensitive(values, "RA"); + QString dec = valueToStringCaseInsensitive(values, "DECL"); + if (!isCalib && (ra.isEmpty() || dec.isEmpty())) { + inputWarnings << QString("Row %1 (%2): missing RA/DECL").arg(record.rowIndex).arg(name); + continue; + } + + QString channel = valueToStringCaseInsensitive(values, "CHANNEL"); + if (channel.isEmpty()) channel = kDefaultChannel; + + double low = 0.0; + double high = 0.0; + const bool lowOk = parseDouble(valueToStringCaseInsensitive(values, "WRANGE_LOW"), &low); + const bool highOk = parseDouble(valueToStringCaseInsensitive(values, "WRANGE_HIGH"), &high); + if (!lowOk || !highOk || high <= low) { + const auto def = defaultWrangeForChannel(channel); + low = def.first; + high = def.second; + } + const QString wrange = QString("%1 %2").arg(formatNumber(low), formatNumber(high)); + + QString slitangle = valueToStringCaseInsensitive(values, "SLITANGLE"); + if (slitangle.isEmpty()) slitangle = kDefaultSlitangle; + QString slitwidth = valueToStringCaseInsensitive(values, "SLITWIDTH"); + if (slitwidth.isEmpty()) slitwidth = kDefaultSlitwidth; + QString exptime = valueToStringCaseInsensitive(values, "EXPTIME"); + if (exptime.isEmpty()) exptime = kDefaultExptime; + int nexp = 1; + if (includeNexp) { + int parsed = 0; + if (parseInt(valueToStringCaseInsensitive(values, "NEXP"), &parsed) && parsed > 0) { + nexp = parsed; + } + } + QString notbefore = valueToStringCaseInsensitive(values, "NOTBEFORE"); + if (notbefore.isEmpty()) notbefore = kDefaultNotBefore; + QString pointmode = valueToStringCaseInsensitive(values, "POINTMODE"); + if (pointmode.isEmpty()) pointmode = kDefaultPointmode; + QString ccdmode = valueToStringCaseInsensitive(values, "CCDMODE"); + if (ccdmode.isEmpty()) ccdmode = kDefaultCcdmode; + QString airmassMax = valueToStringCaseInsensitive(values, "AIRMASS_MAX"); + if (airmassMax.isEmpty()) airmassMax = formatNumber(settings.airmassMax); + QString binspat = valueToStringCaseInsensitive(values, "BINSPAT"); + if (binspat.isEmpty()) binspat = QString::number(kDefaultBin); + QString binspect = valueToStringCaseInsensitive(values, "BINSPECT"); + if (binspect.isEmpty()) binspect = QString::number(kDefaultBin); + QString mag = valueToStringCaseInsensitive(values, "MAGNITUDE"); + if (mag.isEmpty()) mag = formatNumber(kDefaultMagnitude); + QString magsystem = valueToStringCaseInsensitive(values, "MAGSYSTEM"); + if (magsystem.isEmpty()) magsystem = kDefaultMagsystem; + QString magfilter = valueToStringCaseInsensitive(values, "MAGFILTER"); + if (magfilter.isEmpty()) magfilter = kDefaultMagfilter; + QString srcmodel = valueToStringCaseInsensitive(values, "SRCMODEL"); + srcmodel = normalizeSrcmodelValue(srcmodel); + + QStringList fields; + fields << obsId << name << ra << dec + << slitangle << slitwidth; + if (includeNexp) fields << QString::number(nexp); + fields << exptime + << notbefore << pointmode << ccdmode + << airmassMax << binspat << binspect + << channel << wrange << mag << magsystem << magfilter; + if (includeSrcmodel) fields << srcmodel; + + for (QString &field : fields) { + field = csvEscape(field); + } + for (int rep = 0; rep < nexp; ++rep) { + lines << fields.join(","); + } + } + + if (lines.size() <= 1) { + if (!quiet) showInfo(this, "Run OTM", "No valid targets to send to OTM."); + return; + } + + const QString timestamp = QDateTime::currentDateTimeUtc().toString("yyyyMMdd_HHmmss_zzz"); + const QString inputPath = QDir::temp().filePath( + QString("ngps_otm_input_%1_%2.csv").arg(setId).arg(timestamp)); + const QString outputPath = QDir::temp().filePath( + QString("ngps_otm_output_%1_%2.csv").arg(setId).arg(timestamp)); + + QFile inputFile(inputPath); + if (!inputFile.open(QIODevice::WriteOnly | QIODevice::Text)) { + if (!quiet) showWarning(this, "Run OTM", "Unable to write OTM input file."); + return; + } + QTextStream out(&inputFile); + for (const QString &line : lines) { + out << line << "\n"; + } + inputFile.close(); + + if (runOtm_) runOtm_->setEnabled(false); + otmRunning_ = true; + + struct OtmRunContext { + int setId = -1; + QString setName; + QString inputPath; + QString outputPath; + QString timelinePath; + QString timestamp; + double airmassMax = 0.0; + QHash oldSlitwidth; + QHash membersByScienceObsId; + QSet targetColumnNames; + QStringList inputWarnings; + QHash obsOrderByObsId; + QString startUtc; + QStringList coordDiagnostics; + QSet manualUngroupObsIds; + }; + auto context = std::make_shared(); + context->setId = setId; + context->setName = setValues.value("SET_NAME").toString(); + context->inputPath = inputPath; + context->outputPath = outputPath; + context->timestamp = timestamp; + context->oldSlitwidth = oldSlitwidthByObsId; + context->membersByScienceObsId = membersByScienceObsId; + context->targetColumnNames = targetColumnNames; + context->inputWarnings = inputWarnings; + context->obsOrderByObsId = obsOrderByObsId; + context->startUtc = startUtc; + context->airmassMax = settings.airmassMax; + context->coordDiagnostics = coordDiagnostics; + context->manualUngroupObsIds = manualUngroupObsIds; + + const QString pythonCmd = resolveOtmPython(); + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + if (env.contains("PYTHONHOME")) env.remove("PYTHONHOME"); + if (env.contains("PYTHONPATH")) env.remove("PYTHONPATH"); + const QString addPath = QDir(ngpsRoot_).filePath("Python"); + env.insert("PYTHONPATH", addPath); + env.insert("PYTHONNOUSERSITE", "1"); + + QProcess *proc = new QProcess(this); + proc->setProcessEnvironment(env); + proc->setWorkingDirectory(ngpsRoot_); + + QStringList args; + args << scriptPath + << inputPath + << startUtc + << "-seeing" << formatNumber(settings.seeingFwhm) << formatNumber(settings.seeingPivot) + << "-airmass_max" << formatNumber(settings.airmassMax) + << "-out" << outputPath; + if (!settings.useSkySim) args << "-noskysim"; + + auto output = std::make_shared(); + statusBar()->showMessage(QString("Running: %1 %2").arg(pythonCmd, args.join(' ')), 5000); + auto withDiagnostics = [context](const QString &base) { + QString log = base; + if (!context->coordDiagnostics.isEmpty()) { + log += "\n\nFinal coordinates after offsets (deg):\n"; + log += context->coordDiagnostics.join("\n"); + } + return log; + }; + connect(proc, &QProcess::readyReadStandardOutput, this, [proc, output]() { + *output += QString::fromUtf8(proc->readAllStandardOutput()); + }); + connect(proc, &QProcess::readyReadStandardError, this, [proc, output]() { + *output += QString::fromUtf8(proc->readAllStandardError()); + }); + connect(proc, &QProcess::errorOccurred, this, + [this, proc, output, context, quiet, withDiagnostics](QProcess::ProcessError) { + if (runOtm_) runOtm_->setEnabled(true); + otmRunning_ = false; + const bool rerun = otmAutoPending_; + otmAutoPending_ = false; + lastOtmLog_ = withDiagnostics(*output); + QString detail = *output; + if (detail.trimmed().isEmpty()) detail = "Failed to start OTM process."; + if (!quiet) { + showWarning(this, "Run OTM", detail); + } else { + statusBar()->showMessage("OTM failed to start. See OTM log.", 8000); + } + QFile::remove(context->inputPath); + QFile::remove(context->outputPath); + proc->deleteLater(); + if (rerun) scheduleAutoOtmRun(); + }); + connect(proc, QOverload::of(&QProcess::finished), this, + [this, proc, output, context, pythonCmd, env, quiet, withDiagnostics](int code, QProcess::ExitStatus status) { + if (runOtm_) runOtm_->setEnabled(true); + otmRunning_ = false; + const bool rerun = otmAutoPending_; + otmAutoPending_ = false; + lastOtmLog_ = withDiagnostics(*output); + auto cleanupFiles = [context]() { + QFile::remove(context->inputPath); + QFile::remove(context->outputPath); + if (!context->timelinePath.isEmpty()) { + QFile::remove(context->timelinePath); + } + }; + + const QString msg = QString("OTM exit %1 (%2)") + .arg(code) + .arg(status == QProcess::NormalExit ? "normal" : "crash"); + statusBar()->showMessage(msg, 5000); + + if (status != QProcess::NormalExit || code != 0) { + QString detail = *output; + if (detail.trimmed().isEmpty()) detail = msg; + if (!quiet) { + showWarning(this, "Run OTM", detail); + } else { + statusBar()->showMessage("OTM failed. See OTM log.", 8000); + } + cleanupFiles(); + proc->deleteLater(); + if (rerun) scheduleAutoOtmRun(); + return; + } + + QFile outFile(context->outputPath); + if (!outFile.open(QIODevice::ReadOnly | QIODevice::Text)) { + if (!quiet) { + showWarning(this, "Run OTM", "Unable to read OTM output file."); + } else { + statusBar()->showMessage("OTM output missing.", 8000); + } + cleanupFiles(); + proc->deleteLater(); + return; + } + + QTextStream in(&outFile); + QString headerLine; + while (!in.atEnd()) { + headerLine = in.readLine(); + if (!headerLine.trimmed().isEmpty()) break; + } + if (headerLine.trimmed().isEmpty()) { + if (!quiet) { + showWarning(this, "Run OTM", "OTM output file is empty."); + } else { + statusBar()->showMessage("OTM output empty.", 8000); + } + cleanupFiles(); + proc->deleteLater(); + return; + } + + const QStringList headers = parseCsvLine(headerLine); + QHash headerMap; + for (int i = 0; i < headers.size(); ++i) { + headerMap.insert(headers[i].trimmed().toUpper(), i); + } + + auto getField = [&](const QString &name, const QStringList &fields) -> QString { + const int idx = headerMap.value(name.toUpper(), -1); + if (idx < 0 || idx >= fields.size()) return QString(); + return fields.at(idx).trimmed(); + }; + + auto addUpdate = [&](QVariantMap &updates, + const QStringList &fields, + const QString &outCol, + const QString &dbCol, + bool isTimestamp, + bool allowEmptyString, + bool skipIfEmpty) { + if (!context->targetColumnNames.contains(dbCol.toUpper())) return; + QString raw = getField(outCol, fields); + if (isTimestamp) { + raw = normalizeOtmTimestamp(raw); + } else { + raw = raw.trimmed(); + } + if (raw.compare("None", Qt::CaseInsensitive) == 0) { + raw.clear(); + } + if (raw.isEmpty()) { + if (skipIfEmpty) { + return; + } + if (allowEmptyString) { + updates.insert(dbCol, QString()); + } else { + updates.insert(dbCol, QVariant()); + } + } else { + updates.insert(dbCol, raw); + } + }; + + struct AggUpdates { + QVariantMap updates; + QDateTime startMin; + QDateTime endMax; + QString startStr; + QString endStr; + bool hasStart = false; + bool hasEnd = false; + }; + + int rowIndex = 0; + QStringList warnings = context->inputWarnings; + QHash aggByObsId; + + while (!in.atEnd()) { + const QString line = in.readLine(); + if (line.trimmed().isEmpty()) continue; + ++rowIndex; + const QStringList fields = parseCsvLine(line); + if (fields.size() == 1 && fields.at(0).trimmed().isEmpty()) continue; + + const QString obsId = getField("OBSERVATION_ID", fields); + if (obsId.isEmpty()) { + warnings << QString("Output row %1: missing OBSERVATION_ID").arg(rowIndex); + continue; + } + + QVariantMap updates; + addUpdate(updates, fields, "OTMstart", "OTMexp_start", true, false, false); + addUpdate(updates, fields, "OTMend", "OTMexp_end", true, false, false); + addUpdate(updates, fields, "OTMexptime", "OTMexpt", false, false, false); + addUpdate(updates, fields, "OTMslitwidth", "OTMslitwidth", false, false, true); + addUpdate(updates, fields, "OTMpa", "OTMpa", false, false, false); + addUpdate(updates, fields, "OTMslitangle", "OTMslitangle", false, false, false); + addUpdate(updates, fields, "OTMcass", "OTMcass", false, false, false); + addUpdate(updates, fields, "OTMwait", "OTMwait", false, false, false); + addUpdate(updates, fields, "OTMflag", "OTMflag", false, true, false); + addUpdate(updates, fields, "OTMlast", "OTMlast", false, true, false); + addUpdate(updates, fields, "OTMslewgo", "OTMslewgo", true, false, false); + addUpdate(updates, fields, "OTMslew", "OTMslew", false, false, false); + addUpdate(updates, fields, "OTMdead", "OTMdead", false, false, false); + addUpdate(updates, fields, "OTMairmass_start", "OTMairmass_start", false, false, false); + addUpdate(updates, fields, "OTMairmass_end", "OTMairmass_end", false, false, false); + addUpdate(updates, fields, "OTMsky", "OTMsky", false, false, false); + addUpdate(updates, fields, "OTMmoon", "OTMmoon", false, false, false); + addUpdate(updates, fields, "OTMSNR", "OTMSNR", false, false, false); + addUpdate(updates, fields, "OTMres", "OTMres", false, false, false); + addUpdate(updates, fields, "OTMseeing", "OTMseeing", false, false, false); + if (updates.isEmpty()) continue; + + AggUpdates &agg = aggByObsId[obsId]; + agg.updates = updates; + + const QString startStr = updates.value("OTMexp_start").toString().trimmed(); + if (!startStr.isEmpty()) { + const QDateTime dt = parseUtcIso(startStr); + if (dt.isValid() && (!agg.hasStart || dt < agg.startMin)) { + agg.startMin = dt; + agg.startStr = startStr; + agg.hasStart = true; + } + } + const QString endStr = updates.value("OTMexp_end").toString().trimmed(); + if (!endStr.isEmpty()) { + const QDateTime dt = parseUtcIso(endStr); + if (dt.isValid() && (!agg.hasEnd || dt > agg.endMax)) { + agg.endMax = dt; + agg.endStr = endStr; + agg.hasEnd = true; + } + } + } + + int updated = 0; + for (auto it = aggByObsId.begin(); it != aggByObsId.end(); ++it) { + const QString obsId = it.key(); + AggUpdates agg = it.value(); + if (agg.hasStart) agg.updates.insert("OTMexp_start", agg.startStr); + if (agg.hasEnd) agg.updates.insert("OTMexp_end", agg.endStr); + + QVariantMap keyValues; + QStringList members = context->membersByScienceObsId.value(obsId); + if (members.isEmpty()) { + members << obsId; + } + for (const QString &memberObsId : members) { + keyValues.clear(); + keyValues.insert("OBSERVATION_ID", memberObsId); + if (context->oldSlitwidth.contains(memberObsId) && + context->targetColumnNames.contains("OTMSLITWIDTH")) { + keyValues.insert("OTMslitwidth", context->oldSlitwidth.value(memberObsId)); + } + + QString rowError; + if (!dbClient_.updateColumnsByKey(config_.tableTargets, agg.updates, keyValues, &rowError)) { + warnings << QString("Output (OBSERVATION_ID %1): %2") + .arg(memberObsId) + .arg(rowError.isEmpty() ? "Update failed" : rowError); + continue; + } + ++updated; + } + } + + setTargetsPanel_->refresh(); + auto finishSummary = [this, context, updated, warnings, quiet]() { + if (!quiet) { + QString summary = QString("OTM updated %1 targets.").arg(updated); + if (!context->setName.trimmed().isEmpty()) { + summary = QString("OTM updated %1 targets in set \"%2\".") + .arg(updated) + .arg(context->setName.trimmed()); + } + if (!warnings.isEmpty()) { + summary += "\n\nWarnings:\n" + warnings.join("\n"); + } + showInfo(this, "Run OTM", summary); + } else { + statusBar()->showMessage(QString("OTM updated %1 targets.").arg(updated), 5000); + } + }; + + auto runTimeline = [this, context, pythonCmd, env, quiet, cleanupFiles]() { + if (!timelinePanel_) { + cleanupFiles(); + return; + } + const QString timelineScript = QDir(ngpsRoot_).filePath("Python/OTM/otm_timeline.py"); + if (!QFile::exists(timelineScript)) { + if (!quiet) { + showWarning(this, "Run OTM", QString("Timeline script not found: %1") + .arg(timelineScript)); + } else { + statusBar()->showMessage("Timeline script missing.", 8000); + } + cleanupFiles(); + return; + } + + context->timelinePath = QDir::temp().filePath( + QString("ngps_otm_timeline_%1_%2.json").arg(context->setId).arg(context->timestamp)); + + QProcess *timelineProc = new QProcess(this); + timelineProc->setProcessEnvironment(env); + if (!ngpsRoot_.isEmpty()) timelineProc->setWorkingDirectory(ngpsRoot_); + + QStringList targs; + targs << timelineScript + << "--input" << context->inputPath + << "--output" << context->outputPath + << "--json" << context->timelinePath; + if (!context->startUtc.trimmed().isEmpty()) { + targs << "--start" << context->startUtc.trimmed(); + } + + auto timelineOutput = std::make_shared(); + connect(timelineProc, &QProcess::readyReadStandardOutput, this, + [timelineProc, timelineOutput]() { + *timelineOutput += QString::fromUtf8(timelineProc->readAllStandardOutput()); + }); + connect(timelineProc, &QProcess::readyReadStandardError, this, + [timelineProc, timelineOutput]() { + *timelineOutput += QString::fromUtf8(timelineProc->readAllStandardError()); + }); + connect(timelineProc, QOverload::of(&QProcess::finished), this, + [this, timelineProc, timelineOutput, context, quiet, cleanupFiles](int code, + QProcess::ExitStatus status) { + if (status != QProcess::NormalExit || code != 0) { + if (!quiet) { + showWarning(this, "Run OTM", "Timeline generation failed:\n" + *timelineOutput); + } else { + statusBar()->showMessage("Timeline generation failed.", 8000); + } + timelineProc->deleteLater(); + cleanupFiles(); + return; + } + + TimelineData data; + QString parseError; + if (!loadTimelineJson(context->timelinePath, &data, &parseError)) { + if (!quiet) { + showWarning(this, "Run OTM", "Failed to load timeline data:\n" + parseError); + } else { + statusBar()->showMessage("Timeline data unreadable.", 8000); + } + timelineProc->deleteLater(); + cleanupFiles(); + return; + } + + if (!context->obsOrderByObsId.isEmpty() && !data.targets.isEmpty()) { + QVector> order; + order.reserve(data.targets.size()); + for (const TimelineTarget &target : data.targets) { + const int raw = context->obsOrderByObsId.value( + target.obsId, std::numeric_limits::max()); + order.append({raw, target.obsId}); + } + std::sort(order.begin(), order.end(), + [](const auto &a, const auto &b) { + if (a.first != b.first) return a.first < b.first; + return a.second < b.second; + }); + QHash seqByObsId; + int seq = 1; + for (const auto &entry : order) { + if (!seqByObsId.contains(entry.second)) { + seqByObsId.insert(entry.second, seq++); + } + } + for (TimelineTarget &target : data.targets) { + if (seqByObsId.contains(target.obsId)) { + target.obsOrder = seqByObsId.value(target.obsId); + } + } + } + + if (!context->obsOrderByObsId.isEmpty()) { + for (TimelineTarget &target : data.targets) { + if (context->obsOrderByObsId.contains(target.obsId)) { + if (target.obsOrder <= 0) { + target.obsOrder = context->obsOrderByObsId.value(target.obsId); + } + } + } + std::stable_sort(data.targets.begin(), data.targets.end(), + [](const TimelineTarget &a, const TimelineTarget &b) { + if (a.obsOrder > 0 && b.obsOrder > 0) { + return a.obsOrder < b.obsOrder; + } + if (a.obsOrder > 0 || b.obsOrder > 0) { + return a.obsOrder > 0; + } + if (a.startUtc.isValid() && b.startUtc.isValid()) { + return a.startUtc < b.startUtc; + } + return a.name < b.name; + }); + } + + data.airmassLimit = context->airmassMax; + timelinePanel_->setData(data); + const QVariantMap current = setTargetsPanel_->currentRowValues(); + const QString obsId = current.value("OBSERVATION_ID").toString(); + if (!obsId.isEmpty()) { + timelinePanel_->setSelectedObsId(obsId); + } + + timelineProc->deleteLater(); + cleanupFiles(); + }); + + timelineProc->start(pythonCmd, targs); + }; + + finishSummary(); + runTimeline(); + proc->deleteLater(); + if (rerun) scheduleAutoOtmRun(); + }); + + proc->start(pythonCmd, args); + } + + void updateSetViewFromSelection() { + const QVariantMap values = setsPanel_->currentRowValues(); + const QVariant setId = values.value("SET_ID"); + if (!setId.isValid()) { + setTargetsPanel_->clearFixedFilter(); + setTargetsPanel_->refresh(); + if (timelinePanel_) timelinePanel_->clear(); + return; + } + setTargetsPanel_->setFixedFilter("SET_ID", setId.toString()); + setTargetsPanel_->refresh(); + if (timelinePanel_) timelinePanel_->clear(); + scheduleAutoOtmRun(); + } + + void handleTimelineReorder(const QString &fromObsId, const QString &toObsId) { + if (fromObsId.isEmpty()) return; + if (!dbClient_.isOpen()) { + statusBar()->showMessage("Reorder failed: not connected.", 5000); + return; + } + + QString error; + if (toObsId.isEmpty()) { + if (!setTargetsPanel_->moveGroupToTopObsId(fromObsId, &error)) { + statusBar()->showMessage(error.isEmpty() ? "Reorder failed." : error, 5000); + return; + } + } else { + if (!setTargetsPanel_->moveGroupAfterObsId(fromObsId, toObsId, &error)) { + statusBar()->showMessage(error.isEmpty() ? "Reorder failed." : error, 5000); + return; + } + } + + scheduleAutoOtmRun(); + } + +private: + struct SeqProcessConfig { + QString cmd; + QProcessEnvironment env; + QString workDir; + }; + + SeqProcessConfig seqProcessConfig() const { + QSettings settings(kSettingsOrg, kSettingsApp); + QString cmd = settings.value("seqCommand").toString(); + if (cmd.isEmpty()) { + if (!ngpsRoot_.isEmpty()) { + const QString candidate = QDir(ngpsRoot_).filePath("run/seq"); + if (QFile::exists(candidate)) { + cmd = candidate; + } + } + if (cmd.isEmpty()) cmd = "seq"; + } + + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + if (!seqConfigPath_.isEmpty()) { + env.insert("SEQUENCERD_CONFIG", seqConfigPath_); + } + if (!ngpsRoot_.isEmpty()) { + env.insert("NGPS_ROOT", ngpsRoot_); + } + + SeqProcessConfig cfg; + cfg.cmd = cmd; + cfg.env = env; + cfg.workDir = ngpsRoot_; + return cfg; + } + + static bool outputHasState(const QString &output, const QString &state) { + const QString pattern = QString("\\b%1\\b").arg(QRegularExpression::escape(state)); + const QRegularExpression re(pattern, QRegularExpression::CaseInsensitiveOption); + return re.match(output).hasMatch(); + } + + bool createTargetSet(const QString &setName, int *setIdOut, QString *error) { + QList setColumns; + if (!dbClient_.loadColumns(config_.tableSets, setColumns, error)) { + return false; + } + + QVariantMap values; + QSet nullColumns; + values.insert("SET_NAME", setName); + + if (!dbClient_.insertRecord(config_.tableSets, setColumns, values, nullColumns, error)) { + return false; + } + + QVariant idValue; + if (dbClient_.fetchSingleValue("SELECT LAST_INSERT_ID()", {}, &idValue, error)) { + bool ok = false; + int id = idValue.toInt(&ok); + if (ok) { + if (setIdOut) *setIdOut = id; + return true; + } + } + + if (!dbClient_.fetchSingleValue( + QString("SELECT `SET_ID` FROM `%1` WHERE `SET_NAME`=? ORDER BY `SET_ID` DESC LIMIT 1") + .arg(config_.tableSets), + {setName}, &idValue, error)) { + return false; + } + bool ok = false; + int id = idValue.toInt(&ok); + if (!ok) { + if (error) *error = "Unable to determine new SET_ID"; + return false; + } + if (setIdOut) *setIdOut = id; + return true; + } + + void openDatabase() { + dbClient_.close(); + QString error; + if (!dbClient_.connect(config_, &error)) { + connStatus_->setText("Connection failed"); + showError(this, "DB Connection", error.isEmpty() ? "Unable to connect" : error); + return; + } + + connStatus_->setText(QString("Connected: %1@%2:%3/%4") + .arg(config_.user) + .arg(config_.host) + .arg(config_.port) + .arg(config_.schema)); + + setsPanel_->setDatabase(&dbClient_, config_.tableSets); + setTargetsPanel_->setDatabase(&dbClient_, config_.tableTargets); + + settingsForSeq(); + } + + void settingsForSeq() { + seqConfigPath_ = configPath_; + ngpsRoot_ = inferNgpsRootFromConfig(seqConfigPath_); + initializeOtmStart(); + } + + QString resolveOtmPython() const { + QSettings settings(kSettingsOrg, kSettingsApp); + QString cmd = settings.value("otmPython").toString().trimmed(); + if (!cmd.isEmpty()) { + return cmd; + } + + const QString envCmd = qEnvironmentVariable("NGPS_PYTHON"); + if (!envCmd.isEmpty()) return envCmd; + + const QString venv = qEnvironmentVariable("VIRTUAL_ENV"); + if (!venv.isEmpty()) { + const QString candidate = QDir(venv).filePath("bin/python"); + if (QFileInfo::exists(candidate)) return candidate; + } + + const QString homeCandidate = QDir::home().filePath("venvs/ngps/bin/python"); + if (QFileInfo::exists(homeCandidate)) return homeCandidate; + + if (!ngpsRoot_.isEmpty()) { + const QString localVenv = QDir(ngpsRoot_).filePath("venv/bin/python"); + if (QFileInfo::exists(localVenv)) return localVenv; + } + + return "python3"; + } + + OtmSettings loadOtmSettings() const { + QSettings settings(kSettingsOrg, kSettingsApp); + OtmSettings s; + s.seeingFwhm = settings.value("otmSeeingFwhm", 1.1).toDouble(); + s.seeingPivot = settings.value("otmSeeingPivot", 500.0).toDouble(); + s.airmassMax = settings.value("otmAirmassMax", 4.0).toDouble(); + s.useSkySim = settings.value("otmUseSkySim", true).toBool(); + s.pythonCmd = settings.value("otmPython").toString(); + if (s.pythonCmd.trimmed().isEmpty()) { + const QString defaultPython = QDir::home().filePath("venvs/ngps/bin/python"); + if (QFileInfo::exists(defaultPython)) { + s.pythonCmd = defaultPython; + } + } + return s; + } + + void saveOtmSettings(const OtmSettings &settings) { + QSettings cfg(kSettingsOrg, kSettingsApp); + cfg.setValue("otmSeeingFwhm", settings.seeingFwhm); + cfg.setValue("otmSeeingPivot", settings.seeingPivot); + cfg.setValue("otmAirmassMax", settings.airmassMax); + cfg.setValue("otmUseSkySim", settings.useSkySim); + if (settings.pythonCmd.trimmed().isEmpty()) { + cfg.remove("otmPython"); + } else { + cfg.setValue("otmPython", settings.pythonCmd.trimmed()); + } + } + + QString loadOtmStart() const { + QSettings settings(kSettingsOrg, kSettingsApp); + return settings.value("otmStart").toString().trimmed(); + } + + void saveOtmStart() { + if (!otmStartEdit_) return; + if (otmUseNow_ && otmUseNow_->isChecked()) { + return; + } + QString text = otmStartEdit_->text().trimmed(); + if (text.isEmpty()) { + text = estimateTwilightUtc(); + if (text.isEmpty()) text = currentUtcString(); + otmStartEdit_->setText(text); + } + text = normalizeOtmStartText(text, true); + lastOtmStartManual_ = text; + QSettings settings(kSettingsOrg, kSettingsApp); + settings.setValue("otmStart", text); + } + + QString currentUtcString() const { + return QDateTime::currentDateTimeUtc().toString("yyyy-MM-ddTHH:mm:ss.zzz"); + } + + QString normalizeOtmStartText(const QString &text, bool quiet, bool *changed = nullptr) { + QString normalized = text.trimmed(); + if (normalized.contains(' ') && !normalized.contains('T')) { + normalized.replace(' ', 'T'); + } + if (!parseUtcIso(normalized).isValid()) { + QString fallback = estimateTwilightUtc(); + if (fallback.isEmpty()) fallback = currentUtcString(); + if (changed) *changed = true; + if (otmStartEdit_) { + otmStartEdit_->setText(fallback); + } + if (!quiet) { + statusBar()->showMessage( + QString("Invalid OTM start time; using %1").arg(fallback), 8000); + } + return fallback; + } + if (changed) *changed = (normalized != text.trimmed()); + return normalized; + } + + QString estimateTwilightUtc() const { + const QString pythonCmd = resolveOtmPython(); + if (pythonCmd.isEmpty()) return QString(); + + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + if (env.contains("PYTHONHOME")) env.remove("PYTHONHOME"); + if (env.contains("PYTHONPATH")) env.remove("PYTHONPATH"); + const QString addPath = QDir(ngpsRoot_).filePath("Python"); + env.insert("PYTHONPATH", addPath); + env.insert("PYTHONNOUSERSITE", "1"); + + QProcess proc; + proc.setProcessEnvironment(env); + if (!ngpsRoot_.isEmpty()) proc.setWorkingDirectory(ngpsRoot_); + + const QString code = R"PY( +from astropy.time import Time +from astropy.coordinates import EarthLocation, AltAz, get_sun +import astropy.units as u +import numpy as np +import sys + +loc = EarthLocation.of_site('Palomar') +t0 = Time.now() +target = -12.0 +times = t0 + np.linspace(0, 1, 289) * u.day # 5-min steps +alts = get_sun(times).transform_to(AltAz(obstime=times, location=loc)).alt.deg + +for i in range(len(alts) - 1): + if alts[i] > target and alts[i+1] <= target: + frac = (target - alts[i]) / (alts[i+1] - alts[i]) if alts[i+1] != alts[i] else 0.0 + t = times[i] + frac * (times[i+1] - times[i]) + print(t.utc.iso) + sys.exit(0) + +# fallback: any crossing +for i in range(len(alts) - 1): + if (alts[i] - target) * (alts[i+1] - target) <= 0: + frac = (target - alts[i]) / (alts[i+1] - alts[i]) if alts[i+1] != alts[i] else 0.0 + t = times[i] + frac * (times[i+1] - times[i]) + print(t.utc.iso) + sys.exit(0) + +print(t0.utc.iso) +)PY"; + + proc.start(pythonCmd, {"-c", code}); + if (!proc.waitForFinished(6000)) { + return QString(); + } + if (proc.exitStatus() != QProcess::NormalExit || proc.exitCode() != 0) { + return QString(); + } + const QString out = QString::fromUtf8(proc.readAllStandardOutput()).trimmed(); + return out; + } + + void initializeOtmStart() { + if (!otmStartEdit_) return; + const QString saved = loadOtmStart(); + if (!saved.isEmpty()) { + otmStartEdit_->setText(saved); + lastOtmStartManual_ = saved; + return; + } + + const QString twilight = estimateTwilightUtc(); + const QString useVal = twilight.isEmpty() ? currentUtcString() : twilight; + otmStartEdit_->setText(useVal); + lastOtmStartManual_ = useVal; + saveOtmStart(); + } + + void handleOtmUseNowToggle(bool checked) { + if (!otmStartEdit_) return; + if (checked) { + lastOtmStartManual_ = otmStartEdit_->text().trimmed(); + otmStartEdit_->setText(currentUtcString()); + otmStartEdit_->setEnabled(false); + } else { + otmStartEdit_->setEnabled(true); + if (!lastOtmStartManual_.isEmpty()) { + otmStartEdit_->setText(lastOtmStartManual_); + } else { + initializeOtmStart(); + } + } + saveOtmStart(); + scheduleAutoOtmRun(); + } + + void runSeqCommandAndCapture(const QStringList &args, + const std::function &onFinished) { + SeqProcessConfig cfg = seqProcessConfig(); + QProcess *proc = new QProcess(this); + proc->setProcessEnvironment(cfg.env); + if (!cfg.workDir.isEmpty()) { + proc->setWorkingDirectory(cfg.workDir); + } + + auto output = std::make_shared(); + statusBar()->showMessage(QString("Running: %1 %2").arg(cfg.cmd, args.join(' ')), 5000); + connect(proc, &QProcess::readyReadStandardOutput, this, [proc, output]() { + *output += QString::fromUtf8(proc->readAllStandardOutput()); + }); + connect(proc, &QProcess::readyReadStandardError, this, [proc, output]() { + *output += QString::fromUtf8(proc->readAllStandardError()); + }); + connect(proc, QOverload::of(&QProcess::finished), this, + [this, proc, output, onFinished](int code, QProcess::ExitStatus status) { + const QString msg = QString("Seq exit %1 (%2)") + .arg(code) + .arg(status == QProcess::NormalExit ? "normal" : "crash"); + statusBar()->showMessage(msg, 5000); + if (code != 0 && !output->isEmpty()) { + showWarning(this, "Sequencer", *output); + } + if (onFinished) { + onFinished(code, *output); + } + proc->deleteLater(); + }); + + proc->start(cfg.cmd, args); + } + + void seqStartWithStartupCheck() { + runSeqCommandAndCapture({"state"}, [this](int code, const QString &output) { + if (code == 0) { + const bool ready = outputHasState(output, "READY"); + const bool running = outputHasState(output, "RUNNING"); + const bool starting = outputHasState(output, "STARTING"); + if (running || starting) { + statusBar()->showMessage("Sequencer already running/starting.", 5000); + return; + } + if (!ready && !running && !starting) { + runSeqCommandAndCapture({"startup"}, + [this](int, const QString &) { runSeqCommand({"start"}); }); + return; + } + runSeqCommand({"start"}); + return; + } + runSeqCommand({"start"}); + }); + } + + void runSeqCommand(const QStringList &args) { + SeqProcessConfig cfg = seqProcessConfig(); + QProcess *proc = new QProcess(this); + proc->setProcessEnvironment(cfg.env); + if (!cfg.workDir.isEmpty()) { + proc->setWorkingDirectory(cfg.workDir); + } + + auto output = std::make_shared(); + statusBar()->showMessage(QString("Running: %1 %2").arg(cfg.cmd, args.join(' ')), 5000); + connect(proc, &QProcess::readyReadStandardOutput, this, [proc, output]() { + *output += QString::fromUtf8(proc->readAllStandardOutput()); + }); + connect(proc, &QProcess::readyReadStandardError, this, [proc, output]() { + *output += QString::fromUtf8(proc->readAllStandardError()); + }); + connect(proc, QOverload::of(&QProcess::finished), this, + [this, proc, output](int code, QProcess::ExitStatus status) { + const QString msg = QString("Seq exit %1 (%2)") + .arg(code) + .arg(status == QProcess::NormalExit ? "normal" : "crash"); + statusBar()->showMessage(msg, 5000); + if (code != 0 && !output->isEmpty()) { + showWarning(this, "Sequencer", *output); + } + proc->deleteLater(); + }); + + proc->start(cfg.cmd, args); + } + + QTabWidget *tabs_ = nullptr; + TablePanel *setsPanel_ = nullptr; + TablePanel *setTargetsPanel_ = nullptr; + TimelinePanel *timelinePanel_ = nullptr; + + QLabel *connStatus_ = nullptr; + + QPushButton *seqStart_ = nullptr; + QPushButton *seqAbort_ = nullptr; + QPushButton *runOtm_ = nullptr; + QPushButton *showOtmLog_ = nullptr; + QLineEdit *otmStartEdit_ = nullptr; + QCheckBox *otmUseNow_ = nullptr; + QString lastOtmStartManual_; + QString lastOtmLog_; + QTimer *otmAutoTimer_ = nullptr; + bool otmRunning_ = false; + bool otmAutoPending_ = false; + bool closing_ = false; + + DbConfig config_; + DbClient dbClient_; + + QString configPath_; + QString seqConfigPath_; + QString ngpsRoot_; +}; + +int main(int argc, char *argv[]) { + QApplication app(argc, argv); + MainWindow window; + window.resize(1200, 800); + window.show(); + return app.exec(); +} + +#include "main.moc" From a11b2921dc697bc0ede9355b7de168e86cba4533 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sat, 7 Feb 2026 18:01:21 -0800 Subject: [PATCH 22/74] Add seq-progress GUI with full ZMQ progress tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add seq-progress launch script and X11 GUI (utils/seq_progress_gui.cpp) - Implement seq_progress topic publishing in sequencer - Track fine-tune, offset, and target state changes in real-time - Publish progress at key workflow points: * Before/after fine-tuning (tracks fine_tune_pid) * Before/during/after offset operations (tracks offset_active flag) * On target activation and completion - Add seq_progress topic and keys to common/message_keys.h - Add offset_active atomic flag to track offset state - Update utils/CMakeLists.txt to build seq_progress_gui with X11 This enables full 5-phase progress visualization (SLEW → SOLVE → FINE → OFFSET → EXPOSE) for on-sky monitoring and testing of acquisition automation. Co-Authored-By: Claude Opus 4.6 --- common/message_keys.h | 11 + run/seq-progress | 10 + sequencerd/sequence.cpp | 48 ++ sequencerd/sequence.h | 2 + utils/CMakeLists.txt | 28 +- utils/seq_progress_gui.cpp | 1166 ++++++++++++++++++++++++++++++++++++ 6 files changed, 1263 insertions(+), 2 deletions(-) create mode 100755 run/seq-progress create mode 100644 utils/seq_progress_gui.cpp diff --git a/common/message_keys.h b/common/message_keys.h index 0155cc8c..521705d8 100644 --- a/common/message_keys.h +++ b/common/message_keys.h @@ -14,6 +14,7 @@ namespace Topic { inline const std::string TARGETINFO = "tcsd"; inline const std::string SLITD = "slitd"; inline const std::string CAMERAD = "camerad"; + inline const std::string SEQ_PROGRESS = "seq_progress"; } namespace Key { @@ -23,4 +24,14 @@ namespace Key { namespace Camerad { inline const std::string READY = "ready"; } + + namespace SeqProgress { + inline const std::string ONTARGET = "ontarget"; + inline const std::string FINE_TUNE_ACTIVE = "fine_tune_active"; + inline const std::string OFFSET_ACTIVE = "offset_active"; + inline const std::string OFFSET_SETTLE = "offset_settle"; + inline const std::string OBSID = "obsid"; + inline const std::string TARGET_STATE = "target_state"; + inline const std::string EVENT = "event"; + } } diff --git a/run/seq-progress b/run/seq-progress new file mode 100755 index 00000000..8a97d330 --- /dev/null +++ b/run/seq-progress @@ -0,0 +1,10 @@ +#!/bin/bash +# +# Launch the sequencer progress popup GUI +# + +SCRIPT_DIR="$(cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd)" +NGPS_ROOT="${NGPS_ROOT:-$(cd -- "${SCRIPT_DIR}/.." && pwd)}" + +CONFIG="${NGPS_ROOT}/Config/sequencerd.cfg" +exec "${NGPS_ROOT}/bin/seq_progress_gui" --config "${CONFIG}" --group NONE --msgport 0 --poll-ms 10000 diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index 6e3ca870..c7b00d2c 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -182,6 +182,45 @@ namespace Sequencer { /***** Sequencer::Sequence::publish_threadstate *****************************/ + /***** Sequencer::Sequence::publish_progress ********************************/ + /** + * @brief publishes progress information with topic "seq_progress" + * @details publishes fine-grained progress tracking for seq-progress GUI + * + */ + void Sequence::publish_progress() { + nlohmann::json jmessage_out; + jmessage_out["source"] = Sequencer::DAEMON_NAME; + + // Track fine-tune status via fine_tune_pid + jmessage_out["fine_tune_active"] = (this->fine_tune_pid.load() != 0); + + // Track offset status + jmessage_out["offset_active"] = this->offset_active.load(); + + // offset_settle is true when we're waiting after applying offset + // (determined by caller context - set before calling publish_progress) + jmessage_out["offset_settle"] = this->offset_active.load(); // same as offset_active for simplicity + + // ontarget status + jmessage_out["ontarget"] = this->is_ontarget.load(); + + // Current target info + jmessage_out["obsid"] = this->target.obsid; + jmessage_out["target_state"] = this->target.state; + + try { + this->publisher->publish( jmessage_out, "seq_progress" ); + } + catch ( const std::exception &e ) { + logwrite( "Sequencer::Sequence::publish_progress", + "ERROR publishing message: "+std::string(e.what()) ); + return; + } + } + /***** Sequencer::Sequence::publish_progress ********************************/ + + /***** Sequencer::Sequence::broadcast_daemonstate ***************************/ /** * @brief publishes daemonstate and can control seqstate @@ -468,6 +507,7 @@ namespace Sequencer { // message.str(""); message << "TARGETSTATE:" << this->target.state << " TARGET:" << this->target.name << " OBSID:" << this->target.obsid; this->async.enqueue( message.str() ); + this->publish_progress(); // Publish new target info (obsid, target_state) #ifdef LOGLEVEL_DEBUG logwrite( function, "[DEBUG] target found, starting threads" ); #endif @@ -713,7 +753,9 @@ namespace Sequencer { } } else { + this->publish_progress(); // Publish before fine-tune (fine_tune_pid will be set inside run_fine_tune) bool fine_tune_ok = ( run_fine_tune() == NO_ERROR ); + this->publish_progress(); // Publish after fine-tune completes (fine_tune_pid will be 0) if ( !fine_tune_ok ) { this->async.enqueue_and_log( function, "WARNING: fine tune failed; waiting for USER continue to expose" ); if ( !wait_for_user( "waiting for USER to send \"continue\" signal to expose (fine tune failed)" ) ) { @@ -732,15 +774,20 @@ namespace Sequencer { else if ( this->acq_automatic_mode == 3 ) { if ( this->target.offset_ra != 0.0 || this->target.offset_dec != 0.0 ) { this->async.enqueue_and_log( function, "NOTICE: applying target offset automatically" ); + this->offset_active.store(true); + this->publish_progress(); // Publish offset_active=true error |= this->target_offset(); + this->offset_active.store(false); if ( error != NO_ERROR ) { this->thread_error_manager.set( THR_ACQUISITION ); + this->publish_progress(); // Publish with offset error state return; } if ( this->acq_offset_settle > 0 ) { this->async.enqueue_and_log( function, "NOTICE: waiting for offset settle time" ); std::this_thread::sleep_for( std::chrono::duration( this->acq_offset_settle ) ); } + this->publish_progress(); // Publish offset complete } } } @@ -835,6 +882,7 @@ namespace Sequencer { // message.str(""); message << "TARGETSTATE:" << this->target.state << " TARGET:" << this->target.name << " OBSID:" << this->target.obsid; this->async.enqueue( message.str() ); + this->publish_progress(); // Publish target completion state // Check the "dotype" -- // If this was "do one" then do_once is set and get out now. diff --git a/sequencerd/sequence.h b/sequencerd/sequence.h index 1ff77753..267cf85c 100644 --- a/sequencerd/sequence.h +++ b/sequencerd/sequence.h @@ -290,6 +290,7 @@ namespace Sequencer { std::atomic is_ontarget{false}; ///< remotely set by the TCS operator to indicate that the target is ready std::atomic is_usercontinue{false}; ///< remotely set by the user to continue std::atomic fine_tune_pid{0}; ///< fine tune process pid (process group leader) + std::atomic offset_active{false}; ///< tracks offset operation in progress /** @brief safely runs function in a detached thread using lambda to catch exceptions */ @@ -466,6 +467,7 @@ namespace Sequencer { void publish_waitstate(); void publish_daemonstate(); void publish_threadstate(); + void publish_progress(); std::unique_ptr publisher; ///< publisher object std::string publisher_address; ///< publish socket endpoint diff --git a/utils/CMakeLists.txt b/utils/CMakeLists.txt index baa453ba..aa13b417 100644 --- a/utils/CMakeLists.txt +++ b/utils/CMakeLists.txt @@ -63,6 +63,30 @@ target_link_libraries( listener PRIVATE utilities ) # -- ZeroMQ -------------------------------------------------------------------- # -find_library( ZMQPP_LIB zmqpp NAMES libzmqpp PATHS /usr/local/lib ) -find_library( ZMQ_LIB zmq NAMES libzmq PATHS /usr/local/lib ) +find_library( ZMQPP_LIB NAMES zmqpp libzmqpp + PATHS /usr/local/lib /opt/homebrew/lib /usr/local/opt/zmqpp/lib /opt/homebrew/opt/zmqpp/lib ) +find_library( ZMQ_LIB NAMES zmq libzmq + PATHS /usr/local/lib /opt/homebrew/lib /usr/local/opt/zeromq/lib /opt/homebrew/opt/zeromq/lib ) + +# -- X11 GUI tools ------------------------------------------------------------ +# +find_package(X11) +if (X11_FOUND) + add_executable(seq_progress_gui + ${PROJECT_UTILS_DIR}/seq_progress_gui.cpp + ) + target_include_directories(seq_progress_gui PRIVATE + ${X11_INCLUDE_DIR} + ${PROJECT_BASE_DIR}/common + ) + target_link_libraries(seq_progress_gui PRIVATE + ${STDCXXFS_LIB} + utilities + logentry + network + ${ZMQPP_LIB} + ${ZMQ_LIB} + ${X11_LIBRARIES} + ) +endif() diff --git a/utils/seq_progress_gui.cpp b/utils/seq_progress_gui.cpp new file mode 100644 index 00000000..5f60903b --- /dev/null +++ b/utils/seq_progress_gui.cpp @@ -0,0 +1,1166 @@ +// Small X11 sequencer progress popup with ontarget/usercontinue controls. +// Listens to sequencerd async multicast and updates a phase progress bar. + +#define Time X11Time +#include +#include +#include +#undef Time + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common.h" +#include "config.h" +#include "logentry.h" +#include "network.h" + +namespace { + +constexpr int kPhaseCount = 5; +enum PhaseIndex { + PHASE_SLEW = 0, + PHASE_SOLVE = 1, + PHASE_FINE = 2, + PHASE_OFFSET = 3, + PHASE_EXPOSE = 4 +}; + +struct Rect { + int x = 0; + int y = 0; + int w = 0; + int h = 0; + bool contains(int px, int py) const { + return px >= x && px <= (x + w) && py >= y && py <= (y + h); + } +}; + +struct Options { + std::string config_path; + std::string host = "127.0.0.1"; + int nbport = 0; + int msgport = 0; + std::string msggroup = "239.1.1.234"; + bool msggroup_set = false; + std::string acam_config_path; + std::string acam_host; + int acam_nbport = 0; + std::string pub_endpoint; + std::string sub_endpoint; + bool pub_endpoint_set = false; + bool sub_endpoint_set = false; + int poll_ms = 10000; +}; + +struct SequenceState { + bool phase_complete[kPhaseCount] = {false, false, false, false, false}; + bool phase_active[kPhaseCount] = {false, false, false, false, false}; + bool offset_applicable = false; + bool waiting_for_user = false; + bool waiting_for_tcsop = false; + bool ontarget = false; + bool guiding_on = false; + bool guiding_failed = false; + double exposure_progress = 0.0; + double exposure_elapsed = 0.0; + double exposure_total = 0.0; + int current_phase = -1; + bool prev_wait_tcsop = false; + bool prev_wait_guide = false; + std::string seqstate; + std::string waitstate; + std::chrono::steady_clock::time_point last_ontarget; + int current_obsid = -1; + std::string current_target_state; + + void reset() { + for (int i = 0; i < kPhaseCount; ++i) { + phase_complete[i] = false; + phase_active[i] = false; + } + offset_applicable = false; + waiting_for_user = false; + waiting_for_tcsop = false; + ontarget = false; + guiding_on = false; + guiding_failed = false; + exposure_progress = 0.0; + exposure_elapsed = 0.0; + exposure_total = 0.0; + current_phase = -1; + prev_wait_tcsop = false; + prev_wait_guide = false; + seqstate.clear(); + waitstate.clear(); + current_obsid = -1; + current_target_state.clear(); + } + + void reset_progress_only() { + for (int i = 0; i < kPhaseCount; ++i) { + phase_complete[i] = false; + phase_active[i] = false; + } + offset_applicable = false; + waiting_for_user = false; + waiting_for_tcsop = false; + ontarget = false; + guiding_on = false; + guiding_failed = false; + exposure_progress = 0.0; + exposure_elapsed = 0.0; + exposure_total = 0.0; + current_phase = -1; + prev_wait_tcsop = false; + prev_wait_guide = false; + } +}; + +static bool starts_with_local(const std::string &s, const std::string &prefix) { + return s.rfind(prefix, 0) == 0; +} + +static std::string trim_copy(std::string s) { + s.erase(s.begin(), std::find_if(s.begin(), s.end(), [](unsigned char ch) { return !std::isspace(ch); })); + s.erase(std::find_if(s.rbegin(), s.rend(), [](unsigned char ch) { return !std::isspace(ch); }).base(), s.end()); + return s; +} + +static std::vector split_ws(const std::string &s) { + std::istringstream iss(s); + std::vector out; + std::string tok; + while (iss >> tok) out.push_back(tok); + return out; +} + +static bool has_token(const std::vector &tokens, const std::string &needle) { + return std::find(tokens.begin(), tokens.end(), needle) != tokens.end(); +} + +static std::string to_upper_copy(std::string s); + +static std::string strip_token_edges(std::string s) { + while (!s.empty() && !std::isalnum(static_cast(s.front()))) s.erase(s.begin()); + while (!s.empty() && !std::isalnum(static_cast(s.back()))) s.pop_back(); + return s; +} + +static std::vector split_state_tokens(const std::string &s) { + std::vector out; + auto tokens = split_ws(to_upper_copy(s)); + out.reserve(tokens.size()); + for (auto &tok : tokens) { + std::string cleaned = strip_token_edges(tok); + if (!cleaned.empty()) out.push_back(cleaned); + } + return out; +} + +static int parse_int_after_colon(const std::string &s) { + auto pos = s.find(':'); + if (pos == std::string::npos) return 0; + try { + return std::stoi(s.substr(pos + 1)); + } catch (...) { + return 0; + } +} + +static Options parse_args(int argc, char **argv) { + Options opt; + if (const char *env_host = std::getenv("NGPS_HOST"); env_host && *env_host) { + opt.host = env_host; + } + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; + if (arg == "--config" && i + 1 < argc) { + opt.config_path = argv[++i]; + } else if (arg == "--host" && i + 1 < argc) { + opt.host = argv[++i]; + } else if (arg == "--nbport" && i + 1 < argc) { + opt.nbport = std::stoi(argv[++i]); + } else if (arg == "--msgport" && i + 1 < argc) { + opt.msgport = std::stoi(argv[++i]); + } else if (arg == "--group" && i + 1 < argc) { + opt.msggroup = argv[++i]; + opt.msggroup_set = true; + } else if (arg == "--acam-config" && i + 1 < argc) { + opt.acam_config_path = argv[++i]; + } else if (arg == "--acam-host" && i + 1 < argc) { + opt.acam_host = argv[++i]; + } else if (arg == "--acam-nbport" && i + 1 < argc) { + opt.acam_nbport = std::stoi(argv[++i]); + } else if (arg == "--pub-endpoint" && i + 1 < argc) { + opt.pub_endpoint = argv[++i]; + opt.pub_endpoint_set = true; + } else if (arg == "--sub-endpoint" && i + 1 < argc) { + opt.sub_endpoint = argv[++i]; + opt.sub_endpoint_set = true; + } else if (arg == "--poll-ms" && i + 1 < argc) { + opt.poll_ms = std::stoi(argv[++i]); + } else if (arg == "--help" || arg == "-h") { + std::cout << "Usage: seq_progress_gui [--config ] [--host ] [--nbport ] [--msgport ] [--group ]\n" + " [--acam-config ] [--acam-host ] [--acam-nbport ]\n" + " [--pub-endpoint ] [--sub-endpoint ] [--poll-ms ]\n"; + std::exit(0); + } + } + return opt; +} + +static void load_config(const std::string &path, Options &opt) { + if (path.empty()) return; + Config cfg(path); + if (cfg.read_config(cfg) != 0) return; + for (int i = 0; i < cfg.n_entries; ++i) { + if (cfg.param[i] == "NBPORT" && opt.nbport <= 0) { + opt.nbport = std::stoi(cfg.arg[i]); + } else if (cfg.param[i] == "MESSAGEPORT" && opt.msgport <= 0) { + opt.msgport = std::stoi(cfg.arg[i]); + } else if (cfg.param[i] == "MESSAGEGROUP" && !opt.msggroup_set) { + opt.msggroup = cfg.arg[i]; + } else if (cfg.param[i] == "PUB_ENDPOINT" && !opt.pub_endpoint_set) { + opt.pub_endpoint = cfg.arg[i]; + } else if (cfg.param[i] == "SUB_ENDPOINT" && !opt.sub_endpoint_set) { + opt.sub_endpoint = cfg.arg[i]; + } + } +} + +static std::string default_config_path() { + const char *root = std::getenv("NGPS_ROOT"); + if (root && *root) { + return std::string(root) + "/Config/sequencerd.cfg"; + } + return "Config/sequencerd.cfg"; +} + +static std::string default_acam_config_path() { + const char *root = std::getenv("NGPS_ROOT"); + if (root && *root) { + return std::string(root) + "/Config/acamd.cfg"; + } + return "Config/acamd.cfg"; +} + +static void load_acam_config(const std::string &path, Options &opt) { + if (path.empty()) return; + Config cfg(path); + if (cfg.read_config(cfg) != 0) return; + for (int i = 0; i < cfg.n_entries; ++i) { + if (cfg.param[i] == "NBPORT" && opt.acam_nbport <= 0) { + opt.acam_nbport = std::stoi(cfg.arg[i]); + } else if (cfg.param[i] == "HOST" && opt.acam_host.empty()) { + opt.acam_host = cfg.arg[i]; + } + } +} + +static std::string to_lower_copy(std::string s) { + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return std::tolower(c); }); + return s; +} + +static std::string to_upper_copy(std::string s) { + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return std::toupper(c); }); + return s; +} + +} // namespace + +class SeqProgressGui { + public: + SeqProgressGui(const Options &opt) + : options_(opt), + udp_(static_cast(options_.msgport), options_.msggroup), + cmd_iface_("sequencer", options_.host, static_cast(options_.nbport)), + acam_iface_("acam", options_.acam_host, static_cast(options_.acam_nbport)) {} + + bool init() { + display_ = XOpenDisplay(nullptr); + if (!display_) { + std::cerr << "ERROR opening X display\n"; + return false; + } + + int screen = DefaultScreen(display_); + unsigned long black = BlackPixel(display_, screen); + unsigned long white = WhitePixel(display_, screen); + + window_ = XCreateSimpleWindow(display_, DefaultRootWindow(display_), 100, 100, kWinW, kWinH, 1, black, white); + XStoreName(display_, window_, "NGPS Observation Sequence"); + XSelectInput(display_, window_, ExposureMask | ButtonPressMask | KeyPressMask | StructureNotifyMask); + + wm_delete_window_ = XInternAtom(display_, "WM_DELETE_WINDOW", False); + XSetWMProtocols(display_, window_, &wm_delete_window_, 1); + + XMapWindow(display_, window_); + gc_ = XCreateGC(display_, window_, 0, nullptr); + + load_colors(); + load_font(); + compute_layout(); + + bool use_udp = options_.sub_endpoint.empty(); + if (use_udp && options_.msgport > 0 && !options_.msggroup.empty() && to_upper_copy(options_.msggroup) != "NONE") { + udp_fd_ = udp_.Listener(); + if (udp_fd_ < 0) { + std::cerr << "ERROR starting UDP listener\n"; + return false; + } + } + + init_zmq(); + + if (options_.nbport > 0) { + if (cmd_iface_.open() == 0) { + cmd_iface_.send_command("state"); + cmd_iface_.send_command("wstate"); + } + } + if (options_.acam_nbport > 0) { + acam_iface_.open(); + } + + return true; + } + + void run() { + const int xfd = ConnectionNumber(display_); + const int ufd = udp_fd(); + bool running = true; + bool need_redraw = true; + auto last_blink = std::chrono::steady_clock::now(); + bool blink_on = false; + + while (running) { + fd_set fds; + FD_ZERO(&fds); + FD_SET(xfd, &fds); + int maxfd = xfd; + if (ufd >= 0) { + FD_SET(ufd, &fds); + maxfd = std::max(maxfd, ufd); + } + + struct timeval tv; + tv.tv_sec = 0; + tv.tv_usec = 200000; // 200ms + + int ret = select(maxfd + 1, &fds, nullptr, nullptr, &tv); + if (ret > 0) { + if (ufd >= 0 && FD_ISSET(ufd, &fds)) { + std::string msg; + udp_.Receive(msg); + handle_message(msg); + need_redraw = true; + } + if (FD_ISSET(xfd, &fds)) { + while (XPending(display_)) { + XEvent ev; + XNextEvent(display_, &ev); + if (ev.type == Expose) { + need_redraw = true; + } else if (ev.type == ButtonPress) { + handle_click(ev.xbutton.x, ev.xbutton.y); + need_redraw = true; + } else if (ev.type == KeyPress) { + KeySym ks = XLookupKeysym(&ev.xkey, 0); + if (ks == XK_Escape) { + running = false; + } + } else if (ev.type == ClientMessage) { + if (static_cast(ev.xclient.data.l[0]) == wm_delete_window_) { + running = false; + } + } + } + } + } + + auto now = std::chrono::steady_clock::now(); + if (process_zmq()) { + need_redraw = true; + } + if (maybe_poll(now)) { + need_redraw = true; + } + if (std::chrono::duration_cast(now - last_blink).count() > 600) { + blink_on = !blink_on; + last_blink = now; + need_redraw = true; + } + + blink_on_ = blink_on; + if (need_redraw) { + draw(); + need_redraw = false; + } + } + } + + private: + const int kWinW = 900; + const int kWinH = 220; + + Options options_; + Network::UdpSocket udp_; + Network::Interface cmd_iface_; + Network::Interface acam_iface_; + zmqpp::context zmq_context_; + std::unique_ptr zmq_sub_; + std::unique_ptr zmq_pub_; + SequenceState state_; + int udp_fd_ = -1; + std::chrono::steady_clock::time_point last_zmq_seqstate_; + std::chrono::steady_clock::time_point last_zmq_waitstate_; + std::chrono::steady_clock::time_point last_zmq_any_; + std::chrono::steady_clock::time_point last_snapshot_request_; + std::chrono::steady_clock::time_point last_acam_poll_; + std::chrono::steady_clock::time_point last_seq_poll_; + std::chrono::steady_clock::time_point last_seqstate_update_; + std::chrono::steady_clock::time_point last_waitstate_update_; + + Display *display_ = nullptr; + Window window_ = 0; + GC gc_ = 0; + Atom wm_delete_window_{}; + bool blink_on_ = false; + + Rect bar_; + Rect segments_[kPhaseCount]; + Rect ontarget_btn_; + Rect continue_btn_; + Rect guiding_box_; + Rect seqstatus_box_; + + unsigned long color_bg_ = 0; + unsigned long color_text_ = 0; + unsigned long color_complete_ = 0; + unsigned long color_active_ = 0; + unsigned long color_pending_ = 0; + unsigned long color_disabled_ = 0; + unsigned long color_button_ = 0; + unsigned long color_button_disabled_ = 0; + unsigned long color_button_text_ = 0; + unsigned long color_wait_ = 0; + + XFontStruct *font_ = nullptr; + + int udp_fd() const { return udp_fd_; } + + void init_zmq() { + if (!options_.sub_endpoint.empty()) { + try { + zmq_sub_ = std::make_unique(zmq_context_, Common::PubSub::Mode::SUB); + zmq_sub_->connect(options_.sub_endpoint); + zmq_sub_->subscribe("seq_seqstate"); + zmq_sub_->subscribe("seq_waitstate"); + zmq_sub_->subscribe("seq_threadstate"); + zmq_sub_->subscribe("seq_daemonstate"); + zmq_sub_->subscribe("seq_progress"); + zmq_sub_->subscribe("acamd"); + } catch (const std::exception &e) { + std::cerr << "ERROR initializing ZMQ subscriber: " << e.what() << "\n"; + } + } + + if (!options_.pub_endpoint.empty()) { + try { + zmq_pub_ = std::make_unique(zmq_context_, Common::PubSub::Mode::PUB); + zmq_pub_->connect_to_broker(options_.pub_endpoint, "seq_progress_gui"); + } catch (const std::exception &e) { + std::cerr << "ERROR initializing ZMQ publisher: " << e.what() << "\n"; + } + } + + request_snapshot(); + } + + void load_colors() { + int screen = DefaultScreen(display_); + Colormap cmap = DefaultColormap(display_, screen); + color_bg_ = alloc_color(cmap, "#e6e6e6"); + color_text_ = alloc_color(cmap, "#111111"); + color_complete_ = alloc_color(cmap, "#2e7d32"); + color_active_ = alloc_color(cmap, "#f9a825"); + color_pending_ = alloc_color(cmap, "#b0b0b0"); + color_disabled_ = alloc_color(cmap, "#808080"); + color_button_ = alloc_color(cmap, "#1565c0"); + color_button_disabled_ = alloc_color(cmap, "#9e9e9e"); + color_button_text_ = alloc_color(cmap, "#ffffff"); + color_wait_ = alloc_color(cmap, "#c62828"); + } + + unsigned long alloc_color(Colormap cmap, const char *hex) { + XColor color; + XParseColor(display_, cmap, hex, &color); + XAllocColor(display_, cmap, &color); + return color.pixel; + } + + void load_font() { + font_ = XLoadQueryFont(display_, "9x15bold"); + if (!font_) { + font_ = XLoadQueryFont(display_, "fixed"); + } + if (font_) { + XSetFont(display_, gc_, font_->fid); + } + } + + void compute_layout() { + const int margin = 16; + bar_.x = margin; + bar_.y = 48; + bar_.w = kWinW - 2 * margin; + bar_.h = 26; + + int seg_w = bar_.w / kPhaseCount; + for (int i = 0; i < kPhaseCount; ++i) { + segments_[i] = {bar_.x + i * seg_w, bar_.y, seg_w, bar_.h}; + } + segments_[kPhaseCount - 1].w = bar_.w - (seg_w * (kPhaseCount - 1)); + + ontarget_btn_ = {margin, 150, 160, 34}; + continue_btn_ = {margin + 180, 150, 160, 34}; + guiding_box_ = {margin + 520, 150, 160, 34}; + seqstatus_box_ = {guiding_box_.x + guiding_box_.w + 12, 150, 180, 34}; + } + + void draw() { + XSetForeground(display_, gc_, color_bg_); + XFillRectangle(display_, window_, gc_, 0, 0, kWinW, kWinH); + + draw_title(); + draw_bar(); + draw_labels(); + draw_status(); + draw_buttons(); + draw_ontarget_indicator(); + draw_guiding_indicator(); + draw_seqstatus_indicator(); + + XFlush(display_); + } + + void draw_title() { + XSetForeground(display_, gc_, color_text_); + const char *title = "Observation Sequence Progress"; + XDrawString(display_, window_, gc_, 16, 24, title, std::strlen(title)); + } + + void draw_bar() { + for (int i = 0; i < kPhaseCount; ++i) { + unsigned long fill = color_pending_; + if (i == PHASE_OFFSET && !state_.offset_applicable) { + fill = color_disabled_; + } else if (state_.phase_complete[i]) { + fill = color_complete_; + } else if (state_.phase_active[i]) { + fill = color_active_; + } + + XSetForeground(display_, gc_, fill); + XFillRectangle(display_, window_, gc_, segments_[i].x, segments_[i].y, segments_[i].w, segments_[i].h); + + if (i == PHASE_EXPOSE && state_.phase_active[i] && state_.exposure_progress > 0.0) { + int prog_w = static_cast(segments_[i].w * std::min(1.0, state_.exposure_progress)); + XSetForeground(display_, gc_, color_complete_); + XFillRectangle(display_, window_, gc_, segments_[i].x, segments_[i].y, prog_w, segments_[i].h); + } + + XSetForeground(display_, gc_, color_text_); + XDrawRectangle(display_, window_, gc_, segments_[i].x, segments_[i].y, segments_[i].w, segments_[i].h); + } + } + + void draw_labels() { + XSetForeground(display_, gc_, color_text_); + const char *labels[kPhaseCount] = {"SLEW", "ASTROM SOLVE", "FINE TUNE", "OFFSET", "EXPOSURE"}; + int label_y = bar_.y + bar_.h + 16; + for (int i = 0; i < kPhaseCount; ++i) { + int tx = segments_[i].x + 6; + XDrawString(display_, window_, gc_, tx, label_y, labels[i], std::strlen(labels[i])); + } + } + + void draw_status() { + std::string status = "IDLE"; + if (state_.waiting_for_tcsop) { + status = "WAITING FOR TCS OPERATOR (ONTARGET)"; + } else if (state_.waiting_for_user) { + status = "WAITING FOR USER CONTINUE"; + } else if (state_.phase_active[PHASE_SLEW]) { + status = "SLEWING"; + } else if (state_.phase_active[PHASE_SOLVE]) { + status = "ASTROM SOLVE"; + } else if (state_.phase_active[PHASE_FINE]) { + status = "FINE TUNE"; + } else if (state_.phase_active[PHASE_OFFSET]) { + status = "APPLYING OFFSET"; + } else if (state_.phase_active[PHASE_EXPOSE]) { + std::ostringstream oss; + if (state_.exposure_total > 0.0) { + oss.setf(std::ios::fixed); + oss.precision(1); + oss << "EXPOSURE " << state_.exposure_elapsed << " / " << state_.exposure_total << " s"; + } else { + oss << "EXPOSURE " << static_cast(state_.exposure_progress * 100.0) << "%"; + } + status = oss.str(); + } + + unsigned long color = (state_.waiting_for_tcsop || state_.waiting_for_user) && blink_on_ ? color_wait_ : color_text_; + XSetForeground(display_, gc_, color); + XDrawString(display_, window_, gc_, 16, 116, status.c_str(), static_cast(status.size())); + } + + void draw_button(const Rect &r, const char *label, bool enabled) { + unsigned long fill = enabled ? color_button_ : color_button_disabled_; + XSetForeground(display_, gc_, fill); + XFillRectangle(display_, window_, gc_, r.x, r.y, r.w, r.h); + XSetForeground(display_, gc_, color_text_); + XDrawRectangle(display_, window_, gc_, r.x, r.y, r.w, r.h); + XSetForeground(display_, gc_, color_button_text_); + int tx = r.x + 10; + int ty = r.y + 22; + XDrawString(display_, window_, gc_, tx, ty, label, std::strlen(label)); + } + + void draw_buttons() { + draw_button(ontarget_btn_, "ONTARGET", state_.waiting_for_tcsop); + draw_button(continue_btn_, "CONTINUE", state_.waiting_for_user); + } + + void draw_ontarget_indicator() { + const char *label = state_.ontarget ? "ONTARGET: YES" : "ONTARGET: NO"; + unsigned long color = state_.ontarget ? color_complete_ : color_pending_; + XSetForeground(display_, gc_, color); + XFillArc(display_, window_, gc_, 370, 150, 18, 18, 0, 360 * 64); + XSetForeground(display_, gc_, color_text_); + XDrawString(display_, window_, gc_, 394, 164, label, std::strlen(label)); + } + + void draw_guiding_indicator() { + const char *label = state_.guiding_on ? "GUIDING ON" : "GUIDING OFF"; + unsigned long fill = state_.guiding_on ? color_complete_ : color_wait_; + XSetForeground(display_, gc_, fill); + XFillRectangle(display_, window_, gc_, guiding_box_.x, guiding_box_.y, guiding_box_.w, guiding_box_.h); + XSetForeground(display_, gc_, color_text_); + XDrawRectangle(display_, window_, gc_, guiding_box_.x, guiding_box_.y, guiding_box_.w, guiding_box_.h); + XSetForeground(display_, gc_, color_button_text_); + int tx = guiding_box_.x + 10; + int ty = guiding_box_.y + 22; + XDrawString(display_, window_, gc_, tx, ty, label, std::strlen(label)); + } + + void draw_seqstatus_indicator() { + std::string label; + unsigned long fill = color_pending_; + auto seq_tokens = split_state_tokens(state_.seqstate); + const bool has_ready = has_token(seq_tokens, "READY"); + const bool has_notready = has_token(seq_tokens, "NOTREADY") || (has_token(seq_tokens, "NOT") && has_ready); + const bool has_running = has_token(seq_tokens, "RUNNING"); + const bool has_paused = has_token(seq_tokens, "PAUSED"); + const bool has_starting = has_token(seq_tokens, "STARTING"); + const bool has_stopping = has_token(seq_tokens, "STOPPING"); + const bool have_zmq = (zmq_sub_ != nullptr); + const int stale_ms = have_zmq ? 5000 : (options_.poll_ms > 0 ? options_.poll_ms * 2 : 5000); + const bool seq_stale = is_stale(last_seqstate_update_, stale_ms); + const bool wait_stale = is_stale(last_waitstate_update_, stale_ms); + + if (seq_stale && wait_stale) { + label = "STALE"; + fill = color_disabled_; + } else if (state_.waiting_for_tcsop) { + label = "WAITING: TCSOP"; + fill = color_wait_; + } else if (state_.waiting_for_user) { + label = "WAITING: USER"; + fill = color_wait_; + } else if (!state_.waitstate.empty()) { + label = "WAITING"; + fill = color_wait_; + } else if (has_stopping) { + label = "STOPPING"; + fill = color_active_; + } else if (has_starting) { + label = "STARTING"; + fill = color_active_; + } else if (has_paused) { + label = "PAUSED"; + fill = color_wait_; + } else if (has_running) { + label = "RUNNING"; + fill = color_active_; + } else if (has_notready) { + label = "NOTREADY"; + fill = color_disabled_; + } else if (has_ready) { + label = "READY"; + fill = color_complete_; + } else { + label = "UNKNOWN"; + fill = color_pending_; + } + + std::string text = "SEQ " + label; + XSetForeground(display_, gc_, fill); + XFillRectangle(display_, window_, gc_, seqstatus_box_.x, seqstatus_box_.y, seqstatus_box_.w, seqstatus_box_.h); + XSetForeground(display_, gc_, color_text_); + XDrawRectangle(display_, window_, gc_, seqstatus_box_.x, seqstatus_box_.y, seqstatus_box_.w, seqstatus_box_.h); + XSetForeground(display_, gc_, color_button_text_); + int tx = seqstatus_box_.x + 10; + int ty = seqstatus_box_.y + 22; + XDrawString(display_, window_, gc_, tx, ty, text.c_str(), static_cast(text.size())); + } + + void handle_click(int x, int y) { + if (ontarget_btn_.contains(x, y) && state_.waiting_for_tcsop) { + send_command("ontarget"); + } + if (continue_btn_.contains(x, y) && state_.waiting_for_user) { + send_command("usercontinue"); + } + } + + void send_command(const std::string &cmd) { + if (!cmd_iface_.isopen()) { + cmd_iface_.open(); + } + if (cmd_iface_.send_command(cmd) != 0) { + cmd_iface_.reconnect(); + cmd_iface_.send_command(cmd); + } + } + + bool process_zmq() { + if (!zmq_sub_) return false; + if (!zmq_sub_->has_message()) return false; + auto [topic, payload] = zmq_sub_->receive(); + handle_zmq_message(topic, payload); + return true; + } + + void handle_zmq_message(const std::string &topic, const std::string &payload) { + try { + nlohmann::json jmessage = nlohmann::json::parse(payload); + auto now = std::chrono::steady_clock::now(); + last_zmq_any_ = now; + if (topic == "seq_seqstate" && jmessage.contains("seqstate")) { + handle_message("SEQSTATE: " + jmessage["seqstate"].get()); + last_zmq_seqstate_ = now; + } else if (topic == "seq_waitstate") { + static const char *kWaitTokens[] = {"ACAM", "CALIB", "CAMERA", "FLEXURE", "FOCUS", + "POWER", "SLICECAM", "SLIT", "TCS", "ACQUIRE", + "GUIDE", "EXPOSE", "READOUT", "TCSOP", "USER"}; + std::string waitstate; + for (const char *token : kWaitTokens) { + if (jmessage.contains(token) && jmessage[token].is_boolean() && jmessage[token].get()) { + if (!waitstate.empty()) waitstate += " "; + waitstate += token; + } + } + handle_waitstate(waitstate); + last_zmq_waitstate_ = now; + } else if (topic == "seq_progress") { + if (jmessage.contains("obsid") && jmessage["obsid"].is_number_integer()) { + int obsid = jmessage["obsid"].get(); + if (obsid > 0 && state_.current_obsid != obsid) { + state_.reset_progress_only(); + } + state_.current_obsid = obsid; + } + if (jmessage.contains("target_state") && jmessage["target_state"].is_string()) { + std::string tstate = to_upper_copy(jmessage["target_state"].get()); + if (!tstate.empty() && tstate != state_.current_target_state) { + if (tstate == "ACTIVE" || tstate == "COMPLETE" || tstate == "PENDING") { + state_.reset_progress_only(); + } + } + state_.current_target_state = tstate; + } + if (jmessage.contains("event") && jmessage["event"].is_string()) { + std::string event = to_lower_copy(jmessage["event"].get()); + if (event == "target_start" || event == "target_complete" || event == "wait_tcsop") { + state_.reset_progress_only(); + } + } + if (jmessage.contains("ontarget") && jmessage["ontarget"].is_boolean()) { + state_.ontarget = jmessage["ontarget"].get(); + } + if (jmessage.contains("fine_tune_active") && jmessage["fine_tune_active"].is_boolean()) { + bool active = jmessage["fine_tune_active"].get(); + if (active) { + set_phase(PHASE_FINE); + } else if (state_.phase_active[PHASE_FINE]) { + state_.phase_complete[PHASE_FINE] = true; + clear_phase_active(PHASE_FINE); + } + } + bool offset_active = false; + if (jmessage.contains("offset_active") && jmessage["offset_active"].is_boolean()) { + offset_active = jmessage["offset_active"].get(); + } + if (jmessage.contains("offset_settle") && jmessage["offset_settle"].is_boolean()) { + offset_active = offset_active || jmessage["offset_settle"].get(); + } + if (offset_active) { + state_.offset_applicable = true; + set_phase(PHASE_OFFSET); + } else if (state_.phase_active[PHASE_OFFSET]) { + state_.phase_complete[PHASE_OFFSET] = true; + clear_phase_active(PHASE_OFFSET); + } + } else if (topic == "acamd") { + if (jmessage.contains("ACAM_GUIDING") && jmessage["ACAM_GUIDING"].is_boolean()) { + state_.guiding_on = jmessage["ACAM_GUIDING"].get(); + } else if (jmessage.contains("ACAM_ACQUIRE_MODE") && jmessage["ACAM_ACQUIRE_MODE"].is_string()) { + std::string mode = to_lower_copy(jmessage["ACAM_ACQUIRE_MODE"].get()); + state_.guiding_on = (mode.find("guiding") != std::string::npos); + } + } + } catch (const std::exception &) { + // ignore malformed telemetry + } + } + + void request_snapshot() { + if (!zmq_pub_) return; + nlohmann::json jmessage; + jmessage["sequencerd"] = true; + jmessage["acamd"] = true; + zmq_pub_->publish(jmessage, "_snapshot"); + last_snapshot_request_ = std::chrono::steady_clock::now(); + } + + bool is_stale(const std::chrono::steady_clock::time_point &tp, int stale_ms) const { + if (tp.time_since_epoch().count() == 0) return true; + auto now = std::chrono::steady_clock::now(); + return std::chrono::duration_cast(now - tp).count() > stale_ms; + } + + bool should_poll_acam() const { + if (state_.seqstate.find("RUNNING") != std::string::npos) return true; + if (state_.waitstate.find("ACQUIRE") != std::string::npos) return true; + if (state_.waitstate.find("GUIDE") != std::string::npos) return true; + if (state_.waitstate.find("EXPOSE") != std::string::npos) return true; + if (state_.waitstate.find("READOUT") != std::string::npos) return true; + return false; + } + + bool maybe_poll(const std::chrono::steady_clock::time_point &now) { + bool updated = false; + const int stale_ms = 5000; + const bool have_zmq = (zmq_sub_ != nullptr); + const bool stale_seq = have_zmq && + (is_stale(last_zmq_seqstate_, stale_ms) || + is_stale(last_zmq_waitstate_, stale_ms)); + const bool zmq_quiet = have_zmq && is_stale(last_zmq_any_, options_.poll_ms > 0 ? options_.poll_ms * 3 : stale_ms * 3); + const bool allow_tcp_poll = !have_zmq || (zmq_quiet && stale_seq); + + if (options_.poll_ms > 0) { + if (have_zmq && zmq_pub_) { + if (stale_seq && + std::chrono::duration_cast(now - last_snapshot_request_).count() >= stale_ms) { + request_snapshot(); + updated = true; + } + } + if (allow_tcp_poll) { + if (std::chrono::duration_cast(now - last_seq_poll_).count() >= options_.poll_ms) { + poll_sequencer(); + last_seq_poll_ = now; + updated = true; + } + if (std::chrono::duration_cast(now - last_acam_poll_).count() >= options_.poll_ms) { + if (should_poll_acam()) { + poll_acam(); + updated = true; + } + last_acam_poll_ = now; + } + } + } else if (have_zmq && zmq_pub_) { + if (std::chrono::duration_cast(now - last_snapshot_request_).count() >= stale_ms) { + request_snapshot(); + updated = true; + } + } + + return updated; + } + + void poll_status() { + poll_sequencer(); + poll_acam(); + } + + void poll_sequencer() { + if (options_.nbport <= 0) return; + if (!cmd_iface_.isopen()) { + if (cmd_iface_.open() != 0) return; + } + + std::string reply; + if (cmd_iface_.send_command("state", reply, 200) == 0 && !reply.empty()) { + handle_message("SEQSTATE: " + reply); + } else if (!cmd_iface_.isopen()) { + cmd_iface_.reconnect(); + return; + } + + if (cmd_iface_.send_command("wstate") != 0 && !cmd_iface_.isopen()) { + cmd_iface_.reconnect(); + return; + } + } + + void poll_acam() { + if (options_.acam_nbport <= 0) return; + if (!acam_iface_.isopen()) { + if (acam_iface_.open() != 0) return; + } + + std::string reply; + if (acam_iface_.send_command("acquire", reply, 200) == 0 && !reply.empty()) { + std::string lower = to_lower_copy(reply); + if (lower.find("guiding") != std::string::npos) { + state_.guiding_on = true; + state_.guiding_failed = false; + } else if (lower.find("stopped") != std::string::npos || lower.find("acquir") != std::string::npos) { + state_.guiding_on = false; + } + } else if (!acam_iface_.isopen()) { + acam_iface_.reconnect(); + return; + } + } + + void set_phase(int idx) { + if (idx < 0 || idx >= kPhaseCount) return; + if (state_.current_phase != idx) { + if (state_.current_phase >= 0 && state_.current_phase < idx) { + state_.phase_complete[state_.current_phase] = true; + } + for (int i = 0; i < kPhaseCount; ++i) state_.phase_active[i] = false; + state_.current_phase = idx; + } + state_.phase_active[idx] = true; + } + + void clear_phase_active(int idx) { + if (idx < 0 || idx >= kPhaseCount) return; + state_.phase_active[idx] = false; + if (state_.current_phase == idx) { + state_.current_phase = -1; + } + } + + void handle_waitstate(const std::string &waitstate) { + state_.waitstate = waitstate; + last_waitstate_update_ = std::chrono::steady_clock::now(); + auto tokens = split_ws(waitstate); + bool has_tcsop = has_token(tokens, "TCSOP"); + bool has_user = has_token(tokens, "USER"); + bool has_acquire = has_token(tokens, "ACQUIRE"); + bool has_guide = has_token(tokens, "GUIDE"); + bool has_expose = has_token(tokens, "EXPOSE"); + bool has_readout = has_token(tokens, "READOUT"); + + state_.waiting_for_tcsop = has_tcsop; + state_.waiting_for_user = has_user; + + if (!has_tcsop && (has_acquire || has_guide || has_expose || has_readout || has_user)) { + state_.ontarget = true; + } + + if (has_tcsop) { + set_phase(PHASE_SLEW); + state_.ontarget = false; + } else if (state_.prev_wait_tcsop && !has_tcsop) { + state_.phase_complete[PHASE_SLEW] = true; + clear_phase_active(PHASE_SLEW); + } + + if (has_acquire || has_guide) { + set_phase(PHASE_SOLVE); + } + if (has_expose) { + set_phase(PHASE_EXPOSE); + } + if (has_readout) { + state_.phase_complete[PHASE_EXPOSE] = true; + clear_phase_active(PHASE_EXPOSE); + } + + state_.prev_wait_tcsop = has_tcsop; + state_.prev_wait_guide = has_guide; + } + + void handle_message(const std::string &raw) { + std::string msg = trim_copy(raw); + if (starts_with_local(msg, "SEQSTATE:")) { + std::string prev_state = state_.seqstate; + state_.seqstate = trim_copy(msg.substr(9)); + last_seqstate_update_ = std::chrono::steady_clock::now(); + auto is_ready_only = [](const std::string &s) { + auto tokens = split_state_tokens(s); + const bool has_ready = has_token(tokens, "READY"); + const bool has_notready = has_token(tokens, "NOTREADY") || (has_token(tokens, "NOT") && has_ready); + if (!has_ready || has_notready) return false; + if (has_token(tokens, "RUNNING")) return false; + if (has_token(tokens, "STARTING")) return false; + if (has_token(tokens, "STOPPING")) return false; + if (has_token(tokens, "PAUSED")) return false; + return true; + }; + if (is_ready_only(state_.seqstate) && !is_ready_only(prev_state)) { + std::string keep_state = state_.seqstate; + state_.reset(); + state_.seqstate = keep_state; + } + } else if (starts_with_local(msg, "WAITSTATE:")) { + handle_waitstate(trim_copy(msg.substr(10))); + } else if (starts_with_local(msg, "ELAPSEDTIME")) { + auto parts = split_ws(msg); + if (parts.size() >= 2) { + int elapsed_ms = parse_int_after_colon(parts[0]); + int total_ms = parse_int_after_colon(parts[1]); + if (total_ms > 0) { + state_.exposure_elapsed = elapsed_ms / 1000.0; + state_.exposure_total = total_ms / 1000.0; + state_.exposure_progress = std::min(1.0, state_.exposure_elapsed / state_.exposure_total); + set_phase(PHASE_EXPOSE); + } + } + } + + if (msg.find("NOTICE: waiting for TCS operator") != std::string::npos) { + state_.waiting_for_tcsop = true; + set_phase(PHASE_SLEW); + } + if (starts_with_local(msg, "TARGETSTATE:")) { + std::string upper = to_upper_copy(msg); + int obsid = -1; + auto pos = upper.find("OBSID:"); + if (pos != std::string::npos) { + try { + obsid = std::stoi(upper.substr(pos + 6)); + } catch (...) { + } + } + if (obsid > 0 && state_.current_obsid != obsid) { + state_.reset_progress_only(); + state_.current_obsid = obsid; + } + auto state_pos = upper.find("TARGETSTATE:"); + if (state_pos != std::string::npos) { + std::string rest = upper.substr(state_pos + 12); + rest = trim_copy(rest); + auto space = rest.find(' '); + std::string tstate = (space == std::string::npos) ? rest : rest.substr(0, space); + if (!tstate.empty() && tstate != state_.current_target_state) { + if (tstate == "ACTIVE" || tstate == "COMPLETE" || tstate == "PENDING") { + state_.reset_progress_only(); + } + state_.current_target_state = tstate; + } + } + } + if (msg.find("NOTICE: received ontarget") != std::string::npos) { + state_.ontarget = true; + state_.phase_complete[PHASE_SLEW] = true; + clear_phase_active(PHASE_SLEW); + state_.waiting_for_tcsop = false; + state_.last_ontarget = std::chrono::steady_clock::now(); + } + if (msg.find("NOTICE: waiting for USER") != std::string::npos) { + state_.waiting_for_user = true; + } + if (msg.find("NOTICE: received continue") != std::string::npos) { + state_.waiting_for_user = false; + } + if (msg.find("NOTICE: waiting for ACAM guiding") != std::string::npos) { + state_.guiding_on = false; + state_.guiding_failed = false; + set_phase(PHASE_SOLVE); + } + if (msg.find("failed to reach guiding state") != std::string::npos || + msg.find("guiding failed") != std::string::npos) { + state_.guiding_failed = true; + state_.guiding_on = false; + } + if (msg.find("NOTICE: running fine tune command") != std::string::npos) { + state_.guiding_on = true; + state_.guiding_failed = false; + set_phase(PHASE_FINE); + } + if (msg.find("NOTICE: fine tune complete") != std::string::npos) { + state_.guiding_on = true; + state_.guiding_failed = false; + state_.phase_complete[PHASE_FINE] = true; + clear_phase_active(PHASE_FINE); + } + if (msg.find("NOTICE: applying target offset") != std::string::npos) { + state_.offset_applicable = true; + set_phase(PHASE_OFFSET); + } + if (msg.find("NOTICE: waiting for offset settle") != std::string::npos) { + set_phase(PHASE_OFFSET); + } + if (msg.find("NOTICE: running fine tune command") != std::string::npos && + state_.phase_complete[PHASE_SOLVE]) { + clear_phase_active(PHASE_SOLVE); + } + } +}; + +int main(int argc, char **argv) { + std::signal(SIGPIPE, SIG_IGN); + Options opt = parse_args(argc, argv); + if (opt.config_path.empty()) { + opt.config_path = default_config_path(); + } + if (opt.acam_config_path.empty()) { + opt.acam_config_path = default_acam_config_path(); + } + if (opt.acam_host.empty()) { + opt.acam_host = opt.host; + } + + load_config(opt.config_path, opt); + load_acam_config(opt.acam_config_path, opt); + + if (opt.nbport <= 0) { + std::cerr << "ERROR: NBPORT not set (check sequencerd.cfg)\n"; + return 1; + } + if (opt.msgport <= 0 && opt.sub_endpoint.empty()) { + std::cerr << "WARNING: MESSAGEPORT not set and SUB_ENDPOINT empty; only polling will be available\n"; + } + + SeqProgressGui gui(opt); + if (!gui.init()) { + return 1; + } + gui.run(); + return 0; +} From c64d90bc72cb24a498a9f779c71d8001841d2a7f Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sat, 7 Feb 2026 18:07:00 -0800 Subject: [PATCH 23/74] Fix seq_progress_gui build: add utils directory to include paths The seq_progress_gui.cpp includes common.h which needs logentry.h from utils directory. Add PROJECT_UTILS_DIR to target_include_directories to resolve build error. Co-Authored-By: Claude Opus 4.6 --- utils/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/utils/CMakeLists.txt b/utils/CMakeLists.txt index aa13b417..7164d537 100644 --- a/utils/CMakeLists.txt +++ b/utils/CMakeLists.txt @@ -78,6 +78,7 @@ if (X11_FOUND) target_include_directories(seq_progress_gui PRIVATE ${X11_INCLUDE_DIR} ${PROJECT_BASE_DIR}/common + ${PROJECT_UTILS_DIR} ) target_link_libraries(seq_progress_gui PRIVATE ${STDCXXFS_LIB} From 314383366d1b929c4c3d5fb7d547476a8aa81b4e Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sat, 7 Feb 2026 18:09:30 -0800 Subject: [PATCH 24/74] Fix seq_progress_gui linker errors: correct library link order Move utilities library after logentry and network in link order, since those libraries depend on symbols from utilities. Linker requires dependencies to be specified after the libraries that use them. Fixes undefined references to: tmzone_cfg, timestamp_from, get_time, get_latest_datedir, is_owner, has_write_permission, strip_control_characters Co-Authored-By: Claude Opus 4.6 --- utils/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/CMakeLists.txt b/utils/CMakeLists.txt index 7164d537..f565177f 100644 --- a/utils/CMakeLists.txt +++ b/utils/CMakeLists.txt @@ -82,9 +82,9 @@ if (X11_FOUND) ) target_link_libraries(seq_progress_gui PRIVATE ${STDCXXFS_LIB} - utilities logentry network + utilities ${ZMQPP_LIB} ${ZMQ_LIB} ${X11_LIBRARIES} From b170973020c0ec529f53b9bc6daf4ab34ab41e5a Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sat, 7 Feb 2026 18:15:51 -0800 Subject: [PATCH 25/74] Fix calib_set() to work with reference-returning get_info() CalibrationTarget::get_info() returns a const reference (not a pointer) and throws exception if not found. Updated calib_set() to: - Remove unnecessary null check (get_info throws exception instead) - Use '.' instead of '->' to access calinfo members (it's a reference) - Store as 'const auto &' to match return type Fixes compilation errors in sequence.cpp:2332, 2341, 2342, 2353, 2380 Co-Authored-By: Claude Opus 4.6 --- sequencerd/sequence.cpp | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index c7b00d2c..23a877e3 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -2327,19 +2327,16 @@ namespace Sequencer { // Get the calibration target map. // This contains a map of all the required settings, indexed by target name. + // get_info() throws exception if not found, so no need to check for null. // - auto calinfo = this->caltarget.get_info(name); - if (!calinfo) { - logwrite( function, "ERROR unrecognized calibration target: "+name ); - throw std::runtime_error("unrecognized calibration target: "+name); - } + const auto &calinfo = this->caltarget.get_info(name); // set the calib door and cover // std::stringstream cmd; cmd.str(""); cmd << CALIBD_SET - << " door=" << ( calinfo->caldoor ? "open" : "close" ) - << " cover=" << ( calinfo->calcover ? "open" : "close" ); + << " door=" << ( calinfo.caldoor ? "open" : "close" ) + << " cover=" << ( calinfo.calcover ? "open" : "close" ); logwrite( function, "calib: "+cmd.str() ); if ( !this->cancel_flag.load() && @@ -2350,7 +2347,7 @@ namespace Sequencer { // set the internal calibration lamps // - for ( const auto &[lamp,state] : calinfo->lamp ) { + for ( const auto &[lamp,state] : calinfo.lamp ) { if ( this->cancel_flag.load() ) break; cmd.str(""); cmd << lamp << " " << (state?"on":"off"); message.str(""); message << "power " << cmd.str(); @@ -2377,7 +2374,7 @@ namespace Sequencer { // set the lamp modulators // - for ( const auto &[mod,state] : calinfo->lampmod ) { + for ( const auto &[mod,state] : calinfo.lampmod ) { if ( this->cancel_flag.load() ) break; cmd.str(""); cmd << CALIBD_LAMPMOD << " " << mod << " " << (state?1:0) << " 1000"; if ( this->calibd.command( cmd.str() ) != NO_ERROR ) { From d3e6fb6a6314e292277abe4f7cfe87c5d0191e28 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sat, 7 Feb 2026 18:22:58 -0800 Subject: [PATCH 26/74] Fix ngps_db_gui MySQL paths to match main build system Update MySQL Connector/C++ paths in ngps_db_gui CMakeLists.txt to search: - /include (not bare ) - /lib64 (where library actually is on production) - Prioritize mysqlcppconn8 to match main build This matches the paths used successfully by utils/CMakeLists.txt Co-Authored-By: Claude Opus 4.6 --- tools/ngps_db_gui/CMakeLists.txt | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tools/ngps_db_gui/CMakeLists.txt b/tools/ngps_db_gui/CMakeLists.txt index 97a31d82..e556782a 100644 --- a/tools/ngps_db_gui/CMakeLists.txt +++ b/tools/ngps_db_gui/CMakeLists.txt @@ -18,17 +18,18 @@ endif() set(MYSQL_DIR "/usr/local/mysql/connector") find_path(MYSQL_API "mysqlx/xdevapi.h" - PATHS ${MYSQL_DIR} /usr/local/opt/mysql-connector-c++ /opt/homebrew/opt/mysql-connector-c++) + PATHS ${MYSQL_DIR}/include + /usr/local/opt/mysql-connector-c++/include + /opt/homebrew/opt/mysql-connector-c++/include) if (NOT MYSQL_API) message(FATAL_ERROR "mysqlx/xdevapi.h not found. Install MySQL Connector/C++") endif() -find_library(MYSQL_LIB NAMES mysqlcppconnx mysqlcppconn8 mysqlcppconn - PATHS ${MYSQL_DIR} +find_library(MYSQL_LIB NAMES mysqlcppconn8 mysqlcppconnx mysqlcppconn + PATHS ${MYSQL_DIR}/lib64 + ${MYSQL_DIR}/lib /usr/local/opt/mysql-connector-c++/lib - /opt/homebrew/opt/mysql-connector-c++/lib - /usr/local/opt/mysql-connector-c++ - /opt/homebrew/opt/mysql-connector-c++) + /opt/homebrew/opt/mysql-connector-c++/lib) if (NOT MYSQL_LIB) message(FATAL_ERROR "MySQL Connector/C++ library not found") endif() From 771c2de0614cfb89b028ec5977f9c3da9ef67bb2 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sat, 7 Feb 2026 18:26:54 -0800 Subject: [PATCH 27/74] Fix ngps_db_gui Qt5 compatibility: replace QVector with QList MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all QVector with QList for Qt5 compatibility. In Qt6, QVector is an alias for QList, but in Qt5 they are distinct types and cannot be implicitly converted. Changes: - QVector → QList - QVector> → QList> Fixes compilation errors in moveSingleAfterRow, moveSingleToPositionRow, moveSingleToTopRow, and moveGroupAfterRow functions. Co-Authored-By: Claude Opus 4.6 --- tools/ngps_db_gui/main.cpp | 42 +++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/tools/ngps_db_gui/main.cpp b/tools/ngps_db_gui/main.cpp index 89a3578b..72d798a3 100644 --- a/tools/ngps_db_gui/main.cpp +++ b/tools/ngps_db_gui/main.cpp @@ -1324,7 +1324,7 @@ class DbClient { const QString &searchColumn, const QString &searchValue, const QString &orderByColumn, - QVector> &rows, + QList> &rows, QString *error) { rows.clear(); if (!isOpen()) { @@ -1365,7 +1365,7 @@ class DbClient { } mysqlx::SqlResult result = stmt.execute(); for (mysqlx::Row row : result) { - QVector rowValues; + QList rowValues; rowValues.reserve(static_cast(row.colCount())); for (mysqlx::col_count_t i = 0; i < row.colCount(); ++i) { rowValues.append(mysqlValueToVariant(row[i])); @@ -3532,7 +3532,7 @@ private slots: statusLabel_->setText(error.isEmpty() ? "Failed to read columns" : error); return; } - QVector> rows; + QList> rows; const QString searchValue = searchEdit_->text().trimmed(); if (!db_->fetchRows(tableName_, columns_, fixedFilterColumn_, fixedFilterValue_, @@ -3568,7 +3568,7 @@ private slots: const QColor textColor = view_->palette().color(QPalette::Text); const QColor nullColor = view_->palette().color(QPalette::Disabled, QPalette::Text); - for (const QVector &rowValues : rows) { + for (const QList &rowValues : rows) { QList items; items.reserve(columns_.size()); for (int col = 0; col < columns_.size(); ++col) { @@ -4263,8 +4263,8 @@ private slots: const int insertIdx = std::min(toIdx + 1, static_cast(infos.size())); infos.insert(insertIdx, moving); - QVector obsIds; - QVector orderValues; + QList obsIds; + QList orderValues; obsIds.reserve(infos.size()); orderValues.reserve(infos.size()); for (int i = 0; i < infos.size(); ++i) { @@ -4331,8 +4331,8 @@ private slots: if (insertIdx > infos.size()) insertIdx = infos.size(); infos.insert(insertIdx, moving); - QVector obsIds; - QVector orderValues; + QList obsIds; + QList orderValues; obsIds.reserve(infos.size()); orderValues.reserve(infos.size()); for (int i = 0; i < infos.size(); ++i) { @@ -4393,8 +4393,8 @@ private slots: RowInfo moving = infos.takeAt(fromIdx); infos.insert(0, moving); - QVector obsIds; - QVector orderValues; + QList obsIds; + QList orderValues; obsIds.reserve(infos.size()); orderValues.reserve(infos.size()); for (int i = 0; i < infos.size(); ++i) { @@ -4485,8 +4485,8 @@ private slots: const int insertIdx = std::min(toIdx + 1, static_cast(order.size())); order.insert(insertIdx, moving); - QVector obsIds; - QVector orderValues; + QList obsIds; + QList orderValues; int counter = 1; for (const GroupBlock &block : order) { for (const RowInfo &member : block.members) { @@ -4581,8 +4581,8 @@ private slots: if (insertIdx > order.size()) insertIdx = order.size(); order.insert(insertIdx, moving); - QVector obsIds; - QVector orderValues; + QList obsIds; + QList orderValues; int counter = 1; for (const GroupBlock &block : order) { for (const RowInfo &member : block.members) { @@ -4671,8 +4671,8 @@ private slots: GroupBlock moving = order.takeAt(fromIdx); order.insert(0, moving); - QVector obsIds; - QVector orderValues; + QList obsIds; + QList orderValues; int counter = 1; for (const GroupBlock &block : order) { for (const RowInfo &member : block.members) { @@ -4738,7 +4738,7 @@ private slots: if (!db_->loadColumns(tableName_, cols, error)) { return false; } - QVector> rows; + QList> rows; if (!db_->fetchRows(tableName_, cols, "SET_ID", QString::number(setId), "", "", "OBS_ORDER", rows, error)) { return false; @@ -4751,8 +4751,8 @@ private slots: } } if (obsIdCol < 0) return true; - QVector obsIds; - QVector orderValues; + QList obsIds; + QList orderValues; obsIds.reserve(rows.size()); orderValues.reserve(rows.size()); for (int i = 0; i < rows.size(); ++i) { @@ -6756,7 +6756,7 @@ private slots: return; } - QVector> rows; + QList> rows; if (!dbClient_.fetchRows(config_.tableTargets, targetColumns, "SET_ID", QString::number(setId), "", "", "OBS_ORDER", @@ -6828,7 +6828,7 @@ private slots: QHash groups; int rowIndex = 0; - for (const QVector &row : rows) { + for (const QList &row : rows) { ++rowIndex; QVariantMap values; QSet nullColumns; From f24ef1312d10e1b31cbbb942ad091b3a50dcdc7c Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sat, 7 Feb 2026 18:50:41 -0800 Subject: [PATCH 28/74] Add ACAM_GUIDING status and fix offset_goal to preserve guiding 1. Add ACAM_GUIDING status publishing to snapshot: - Publish ACAM_ACQUIRE_MODE, ACAM_GUIDING, and ACAM_ACQUIRING keys - Enables seq-progress GUI to display real-time guiding indicator - ACAM_GUIDING = true when mode is 'guiding' 2. Fix offset_goal to preserve guiding state during fine-tuning: - When already guiding, call reset_offset_params() instead of forcing re-acquisition - Prevents unwanted re-acquisition when sequencer sends offsets via 'acam putonslit' - System remains guiding and smoothly applies new goal coordinates - Fixes issue where fine-tuning would restart acquisition unnecessarily 3. putonslit_offset tracking already exists in this branch (no changes needed) These changes enable proper fine-tuning behavior where the system stays guiding while applying small offsets, rather than dropping out of guiding and re-acquiring the target. Co-Authored-By: Claude Opus 4.6 --- acamd/acam_interface.cpp | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/acamd/acam_interface.cpp b/acamd/acam_interface.cpp index b61f0427..738c43e1 100644 --- a/acamd/acam_interface.cpp +++ b/acamd/acam_interface.cpp @@ -1453,6 +1453,11 @@ namespace Acam { this->motion.get_current_coverpos() : "not_connected" ); + std::string mode = this->target.acquire_mode_string(); + jmessage_out["ACAM_ACQUIRE_MODE"] = mode; + jmessage_out["ACAM_GUIDING"] = ( mode == "guiding" ); + jmessage_out["ACAM_ACQUIRING"] = ( mode == "acquiring" ); + try { this->publisher->publish( jmessage_out ); } @@ -5522,12 +5527,8 @@ logwrite( function, message.str() ); retstring = message.str(); if ( this->target.acquire_mode == Acam::TARGET_GUIDE ) { - this->target.acquire_mode = Acam::TARGET_ACQUIRE; - this->target.nacquired = 0; - this->target.attempts = 0; - this->target.sequential_failures = 0; - this->target.timeout_time = std::chrono::steady_clock::now() - + std::chrono::duration(this->target.timeout); + // Keep GUIDE mode; just reset filtering so the new goal takes effect quickly. + this->target.reset_offset_params(); } return NO_ERROR; From 71c058db60cd3b8ca255a8d73af4fee7d506a819 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sat, 7 Feb 2026 19:04:58 -0800 Subject: [PATCH 29/74] Fix clearlasttarget to also clear coordinates for full re-acquisition The clearlasttarget test command was only clearing last_target name but not the last_ra_hms and last_dec_dms coordinates. This caused the system to still skip acquisition and telescope move because it detected matching coordinates. Now clears all three: - last_target (target name) - last_ra_hms (RA coordinates) - last_dec_dms (DEC coordinates) This allows full re-acquisition of the same target for testing purposes. Usage: seq test clearlasttarget Co-Authored-By: Claude Opus 4.6 --- sequencerd/sequence.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index 23a877e3..5110ec05 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -4771,13 +4771,16 @@ namespace Sequencer { else // --------------------------------------------------------- - // clearlasttarget -- clear the last target name, allowing repointing - // to the same target (otherwise move_to_target won't - // repoint the telescope if the name is the same) + // clearlasttarget -- clear the last target name and coordinates, allowing + // full re-acquisition of the same target (otherwise + // move_to_target and acquisition will skip if coords match) // --------------------------------------------------------- // if ( testname == "clearlasttarget" ) { this->last_target=""; + this->last_ra_hms=""; + this->last_dec_dms=""; + this->async.enqueue_and_log( function, "cleared last target coordinates" ); error=NO_ERROR; } else From ae8dd5a09de1eef459c7ae9924efa0906a9e85b7 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sat, 7 Feb 2026 19:07:22 -0800 Subject: [PATCH 30/74] Fix acquisition skipping logic to require valid last coordinates CRITICAL BUG FIX: Acquisition was being skipped inappropriately in acqmode 2/3 because the repeat-target check didn't verify that last coordinates were valid. Problem: When last_ra_hms and last_dec_dms were empty (after system restart, after clearlasttarget, or on first target), the comparison could incorrectly identify a new target as a repeat target, causing acquisition to be skipped. Fix: Only treat as repeat target if: 1. last_ra_hms is not empty AND 2. last_dec_dms is not empty AND 3. coordinates match current target Applied fix to two locations: - Line 729: acquisition skipping check in acqmode 2/3 - Line 1000: virtual slit mode acquire check This ensures acquisition always runs for genuinely new targets. Co-Authored-By: Claude Opus 4.6 --- sequencerd/sequence.cpp | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index 5110ec05..648ddbf7 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -725,8 +725,11 @@ namespace Sequencer { else { // Check if target coordinates match the last target (same logic as in move_to_target) // If so, skip acquisition for repeat target + // Only skip if we have valid last coordinates (not empty) // - bool is_repeat_target = ( this->target.ra_hms == this->last_ra_hms && + bool is_repeat_target = ( !this->last_ra_hms.empty() && + !this->last_dec_dms.empty() && + this->target.ra_hms == this->last_ra_hms && this->target.dec_dms == this->last_dec_dms ); if ( is_repeat_target ) { @@ -993,8 +996,10 @@ namespace Sequencer { break; case Sequencer::VSM_ACQUIRE: // uses virtual-mode width and offset for acquire, - // but only for new targets - if ( this->target.ra_hms == this->last_ra_hms && + // but only for new targets (skip if repeat target with valid last coords) + if ( !this->last_ra_hms.empty() && + !this->last_dec_dms.empty() && + this->target.ra_hms == this->last_ra_hms && this->target.dec_dms == this->last_dec_dms ) { return NO_ERROR; } From 824e5a18f77db98a58f0380e3a4ce34a8f1fb39e Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sat, 7 Feb 2026 19:17:50 -0800 Subject: [PATCH 31/74] Remove is_repeat_target acquisition skipping logic from acqmode 2/3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove all acquisition skipping logic that was added for repeat targets in acqmode 2 and 3. This logic was causing the system to skip acquisition inappropriately and go straight to expose. Removed: 1. is_repeat_target check and skipping logic in sequence_start (lines 726-737) 2. Coordinate matching check in virtual slit mode VSM_ACQUIRE Restored original acqmode 2/3 behavior: - acqmode 2: wait for user → start acquisition → wait for guiding → fine-tune → wait for user → expose - acqmode 3: start acquisition → wait for guiding → fine-tune → apply offset → expose The repeat target optimization can be re-added later after proper testing, but for now restoring the original working behavior. Co-Authored-By: Claude Opus 4.6 --- sequencerd/sequence.cpp | 105 ++++++++++++++++------------------------ 1 file changed, 42 insertions(+), 63 deletions(-) diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index 648ddbf7..c663869f 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -723,75 +723,61 @@ namespace Sequencer { } } else { - // Check if target coordinates match the last target (same logic as in move_to_target) - // If so, skip acquisition for repeat target - // Only skip if we have valid last coordinates (not empty) - // - bool is_repeat_target = ( !this->last_ra_hms.empty() && - !this->last_dec_dms.empty() && - this->target.ra_hms == this->last_ra_hms && - this->target.dec_dms == this->last_dec_dms ); - - if ( is_repeat_target ) { - this->async.enqueue_and_log( function, "NOTICE: skipping acquisition for repeat target" ); - } - else { - if ( this->acq_automatic_mode == 2 ) { - if ( !wait_for_user( "waiting for USER to send \"continue\" signal to start acquisition" ) ) { - this->async.enqueue_and_log( function, "NOTICE: sequence cancelled" ); - return; - } + if ( this->acq_automatic_mode == 2 ) { + if ( !wait_for_user( "waiting for USER to send \"continue\" signal to start acquisition" ) ) { + this->async.enqueue_and_log( function, "NOTICE: sequence cancelled" ); + return; } + } - this->async.enqueue_and_log( function, "NOTICE: starting acquisition" ); - std::thread( &Sequencer::Sequence::dothread_acquisition, this ).detach(); + this->async.enqueue_and_log( function, "NOTICE: starting acquisition" ); + std::thread( &Sequencer::Sequence::dothread_acquisition, this ).detach(); - long acqerr = wait_for_guiding(); - if ( acqerr != NO_ERROR ) { - std::string reason = ( acqerr == TIMEOUT ? "timeout" : "error" ); - this->async.enqueue_and_log( function, "WARNING: failed to reach guiding state ("+reason+"); falling back to manual continue" ); - if ( !wait_for_user( "waiting for USER to send \"continue\" signal to expose (guiding failed)" ) ) { + long acqerr = wait_for_guiding(); + if ( acqerr != NO_ERROR ) { + std::string reason = ( acqerr == TIMEOUT ? "timeout" : "error" ); + this->async.enqueue_and_log( function, "WARNING: failed to reach guiding state ("+reason+"); falling back to manual continue" ); + if ( !wait_for_user( "waiting for USER to send \"continue\" signal to expose (guiding failed)" ) ) { + this->async.enqueue_and_log( function, "NOTICE: sequence cancelled" ); + return; + } + } + else { + this->publish_progress(); // Publish before fine-tune (fine_tune_pid will be set inside run_fine_tune) + bool fine_tune_ok = ( run_fine_tune() == NO_ERROR ); + this->publish_progress(); // Publish after fine-tune completes (fine_tune_pid will be 0) + if ( !fine_tune_ok ) { + this->async.enqueue_and_log( function, "WARNING: fine tune failed; waiting for USER continue to expose" ); + if ( !wait_for_user( "waiting for USER to send \"continue\" signal to expose (fine tune failed)" ) ) { this->async.enqueue_and_log( function, "NOTICE: sequence cancelled" ); return; } } - else { - this->publish_progress(); // Publish before fine-tune (fine_tune_pid will be set inside run_fine_tune) - bool fine_tune_ok = ( run_fine_tune() == NO_ERROR ); - this->publish_progress(); // Publish after fine-tune completes (fine_tune_pid will be 0) - if ( !fine_tune_ok ) { - this->async.enqueue_and_log( function, "WARNING: fine tune failed; waiting for USER continue to expose" ); - if ( !wait_for_user( "waiting for USER to send \"continue\" signal to expose (fine tune failed)" ) ) { + + if ( fine_tune_ok ) { + if ( this->acq_automatic_mode == 2 ) { + if ( !wait_for_user( "waiting for USER to send \"continue\" signal to expose" ) ) { this->async.enqueue_and_log( function, "NOTICE: sequence cancelled" ); return; } } - - if ( fine_tune_ok ) { - if ( this->acq_automatic_mode == 2 ) { - if ( !wait_for_user( "waiting for USER to send \"continue\" signal to expose" ) ) { - this->async.enqueue_and_log( function, "NOTICE: sequence cancelled" ); + else if ( this->acq_automatic_mode == 3 ) { + if ( this->target.offset_ra != 0.0 || this->target.offset_dec != 0.0 ) { + this->async.enqueue_and_log( function, "NOTICE: applying target offset automatically" ); + this->offset_active.store(true); + this->publish_progress(); // Publish offset_active=true + error |= this->target_offset(); + this->offset_active.store(false); + if ( error != NO_ERROR ) { + this->thread_error_manager.set( THR_ACQUISITION ); + this->publish_progress(); // Publish with offset error state return; } - } - else if ( this->acq_automatic_mode == 3 ) { - if ( this->target.offset_ra != 0.0 || this->target.offset_dec != 0.0 ) { - this->async.enqueue_and_log( function, "NOTICE: applying target offset automatically" ); - this->offset_active.store(true); - this->publish_progress(); // Publish offset_active=true - error |= this->target_offset(); - this->offset_active.store(false); - if ( error != NO_ERROR ) { - this->thread_error_manager.set( THR_ACQUISITION ); - this->publish_progress(); // Publish with offset error state - return; - } - if ( this->acq_offset_settle > 0 ) { - this->async.enqueue_and_log( function, "NOTICE: waiting for offset settle time" ); - std::this_thread::sleep_for( std::chrono::duration( this->acq_offset_settle ) ); - } - this->publish_progress(); // Publish offset complete + if ( this->acq_offset_settle > 0 ) { + this->async.enqueue_and_log( function, "NOTICE: waiting for offset settle time" ); + std::this_thread::sleep_for( std::chrono::duration( this->acq_offset_settle ) ); } + this->publish_progress(); // Publish offset complete } } } @@ -995,14 +981,7 @@ namespace Sequencer { modestr = "EXPOSE"; break; case Sequencer::VSM_ACQUIRE: - // uses virtual-mode width and offset for acquire, - // but only for new targets (skip if repeat target with valid last coords) - if ( !this->last_ra_hms.empty() && - !this->last_dec_dms.empty() && - this->target.ra_hms == this->last_ra_hms && - this->target.dec_dms == this->last_dec_dms ) { - return NO_ERROR; - } + // uses virtual-mode width and offset for acquire slitcmd << this->slitwidthacquire << " " << this->slitoffsetacquire; modestr = "ACQUIRE"; break; From f8a5c9df7996420a77dd73028a1edd049c71ef4c Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sat, 7 Feb 2026 19:27:25 -0800 Subject: [PATCH 32/74] Make target_offset guiding-safe via acamd offsetgoal (from PR #390) When applying target offsets, check if ACAM is currently guiding. If so, use acamd offsetgoal command instead of TCS PT offset to preserve guiding state. Changes in target_offset(): 1. Query ACAM acquire status to check if guiding 2. If guiding: send offsetgoal to acamd (converts arcsec to degrees) 3. If not guiding: fall back to TCS PT offset (original behavior) This prevents target offsets from interrupting the guiding loop, allowing smooth offset application while maintaining lock on the guide star. Merged from: https://github.com/CaltechOpticalObservatories/NGPS/pull/390 Original commit: 8fd1208c Make targetoffset guiding-safe via acamd offsetgoal Co-Authored-By: Claude Opus 4.6 --- sequencerd/sequence.cpp | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index c663869f..1f6ed50c 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -3596,6 +3596,26 @@ namespace Sequencer { const std::string function("Sequencer::Sequence::target_offset"); long error=NO_ERROR; + bool is_guiding = false; + std::string reply; + if ( this->acamd.command( ACAMD_ACQUIRE, reply ) == NO_ERROR ) { + if ( reply.find( "guiding" ) != std::string::npos ) is_guiding = true; + } + else { + logwrite( function, "ERROR reading ACAM guide state, falling back to TCS offset" ); + } + + if ( is_guiding ) { + // ACAMD_OFFSETGOAL expects degrees; target offsets are arcsec + const double dra_deg = this->target.offset_ra / 3600.0; + 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; + error = this->acamd.command( cmd.str() ); + logwrite( function, "sent "+cmd.str()+" (guiding)" ); + return error; + } + error = this->tcsd.command( TCSD_ZERO_OFFSETS ); std::stringstream cmd; From 1f19d41b10f0b87d9ba0bab0dcfc6353eecc3f4d Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sat, 7 Feb 2026 19:30:25 -0800 Subject: [PATCH 33/74] Change acquisition slit width from 0.5 to 0.4 arcsec Update VIRTUAL_SLITW_ACQUIRE from 0.5 to 0.4 arcsec for tighter slit during target acquisition. This results in 'slit set 0.4 -3.0' instead of 'slit set 0.5 -3.0' during acquisition phase. The slit offset remains at -3.0 arcsec for acquisition and +3.0 for expose. Co-Authored-By: Claude Opus 4.6 --- Config/sequencerd.cfg.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config/sequencerd.cfg.in b/Config/sequencerd.cfg.in index d9b0b141..c70e9b5f 100644 --- a/Config/sequencerd.cfg.in +++ b/Config/sequencerd.cfg.in @@ -107,7 +107,7 @@ CALIB_DOOR__SHUTDOWN=close # Virtual Slit Mode slit offset positions # units are arcseconds # -VIRTUAL_SLITW_ACQUIRE=0.5 # slit width during acquire +VIRTUAL_SLITW_ACQUIRE=0.4 # slit width during acquire VIRTUAL_SLITO_ACQUIRE=-3.0 # slit offset for acquiring target VIRTUAL_SLITO_EXPOSE=3.0 # slit offset for science exposure From 7dccbf01b07b34d6db96072367d7e5472f88a157 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sat, 7 Feb 2026 20:04:26 -0800 Subject: [PATCH 34/74] Enable xterm display for fine-tuning step Set ACQ_FINE_TUNE_XTERM=1 to display the fine-tuning command (ngps_acq) output in a separate xterm window. This allows real-time monitoring of the fine-tuning process during observations. Co-Authored-By: Claude Opus 4.6 --- Config/sequencerd.cfg.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config/sequencerd.cfg.in b/Config/sequencerd.cfg.in index c70e9b5f..ca5e92db 100644 --- a/Config/sequencerd.cfg.in +++ b/Config/sequencerd.cfg.in @@ -162,7 +162,7 @@ ACQUIRE_MIN_REPEAT=2 # minimum number of sequential successful a ACQUIRE_TCS_MAX_OFFSET=60 # the maximum allowable offset sent to the TCS, in arcsec ACQ_AUTOMATIC_MODE=1 # 1=legacy, 2=semi-auto, 3=auto ACQ_FINE_TUNE_CMD=ngps_acq # command to run after guiding for final fine tune -ACQ_FINE_TUNE_XTERM=0 # run fine tune in xterm (0/1) +ACQ_FINE_TUNE_XTERM=1 # run fine tune in xterm (0/1) ACQ_OFFSET_SETTLE=0 # seconds to wait after automatic offset # Calibration Settings From 57365195c97272a26f19403413a3eddc33e7bf94 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sat, 7 Feb 2026 20:28:46 -0800 Subject: [PATCH 35/74] Replace xterm fine-tune display with simple log file Replace the xterm-based fine-tune display with simple log file redirection to /tmp/ngps_acq.log. This captures all diagnostic output from ngps_acq without requiring X11/DISPLAY setup for the daemon. Changes: - When ACQ_FINE_TUNE_XTERM=1: redirect stdout/stderr to /tmp/ngps_acq.log - Removed xterm launch code (execlp with xterm) - Simplified to single execl() path with optional redirection - Updated config comment to reflect new behavior Usage: tail -f /tmp/ngps_acq.log # Watch fine-tune progress in real-time The log file is appended to on each run, so you can see history of multiple fine-tune attempts. Co-Authored-By: Claude Opus 4.6 --- Config/sequencerd.cfg.in | 2 +- sequencerd/sequence.cpp | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/Config/sequencerd.cfg.in b/Config/sequencerd.cfg.in index ca5e92db..c9a03ada 100644 --- a/Config/sequencerd.cfg.in +++ b/Config/sequencerd.cfg.in @@ -162,7 +162,7 @@ ACQUIRE_MIN_REPEAT=2 # minimum number of sequential successful a ACQUIRE_TCS_MAX_OFFSET=60 # the maximum allowable offset sent to the TCS, in arcsec ACQ_AUTOMATIC_MODE=1 # 1=legacy, 2=semi-auto, 3=auto ACQ_FINE_TUNE_CMD=ngps_acq # command to run after guiding for final fine tune -ACQ_FINE_TUNE_XTERM=1 # run fine tune in xterm (0/1) +ACQ_FINE_TUNE_XTERM=1 # log fine tune output to /tmp/ngps_acq.log (0/1) ACQ_OFFSET_SETTLE=0 # seconds to wait after automatic offset # Calibration Settings diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index 1f6ed50c..6f692650 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -654,20 +654,18 @@ namespace Sequencer { if ( this->acq_fine_tune_cmd.empty() ) return NO_ERROR; this->async.enqueue_and_log( function, "NOTICE: running fine tune command: "+this->acq_fine_tune_cmd ); + // Build command with optional logging redirection + std::string cmd_to_run = this->acq_fine_tune_cmd; if ( this->acq_fine_tune_xterm ) { - this->async.enqueue_and_log( function, "NOTICE: launching fine tune in xterm" ); + cmd_to_run += " >> /tmp/ngps_acq.log 2>&1"; + this->async.enqueue_and_log( function, "NOTICE: logging fine tune output to /tmp/ngps_acq.log" ); } pid_t pid = fork(); if ( pid == 0 ) { // make a dedicated process group so we can signal the whole tree setpgid( 0, 0 ); - if ( this->acq_fine_tune_xterm ) { - execlp( "xterm", "xterm", "-T", "NGPS Fine Tune", "-e", - "sh", "-lc", this->acq_fine_tune_cmd.c_str(), (char*)nullptr ); - // fall through if xterm is missing - } - execl( "/bin/sh", "sh", "-c", this->acq_fine_tune_cmd.c_str(), (char*)nullptr ); + execl( "/bin/sh", "sh", "-c", cmd_to_run.c_str(), (char*)nullptr ); _exit(127); } if ( pid < 0 ) { From 104e0d039d99ad0f7fee379236d2cdd482b1cba8 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sat, 7 Feb 2026 20:36:27 -0800 Subject: [PATCH 36/74] Rename ACQ_FINE_TUNE_XTERM to ACQ_FINE_TUNE_LOG Rename the config setting and variable from ACQ_FINE_TUNE_XTERM to ACQ_FINE_TUNE_LOG to accurately reflect its current purpose (logging to file rather than displaying in xterm). Changes: - Config: ACQ_FINE_TUNE_XTERM -> ACQ_FINE_TUNE_LOG - Variable: acq_fine_tune_xterm -> acq_fine_tune_log - Updated comments to reflect log file behavior No functional change, just more accurate naming. Co-Authored-By: Claude Opus 4.6 --- Config/sequencerd.cfg.in | 2 +- sequencerd/sequence.cpp | 2 +- sequencerd/sequence.h | 4 ++-- sequencerd/sequencer_server.cpp | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Config/sequencerd.cfg.in b/Config/sequencerd.cfg.in index c9a03ada..5801f3a0 100644 --- a/Config/sequencerd.cfg.in +++ b/Config/sequencerd.cfg.in @@ -162,7 +162,7 @@ ACQUIRE_MIN_REPEAT=2 # minimum number of sequential successful a ACQUIRE_TCS_MAX_OFFSET=60 # the maximum allowable offset sent to the TCS, in arcsec ACQ_AUTOMATIC_MODE=1 # 1=legacy, 2=semi-auto, 3=auto ACQ_FINE_TUNE_CMD=ngps_acq # command to run after guiding for final fine tune -ACQ_FINE_TUNE_XTERM=1 # log fine tune output to /tmp/ngps_acq.log (0/1) +ACQ_FINE_TUNE_LOG=1 # log fine tune output to /tmp/ngps_acq.log (0/1) ACQ_OFFSET_SETTLE=0 # seconds to wait after automatic offset # Calibration Settings diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index 6f692650..c91ae2e2 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -656,7 +656,7 @@ namespace Sequencer { // Build command with optional logging redirection std::string cmd_to_run = this->acq_fine_tune_cmd; - if ( this->acq_fine_tune_xterm ) { + if ( this->acq_fine_tune_log ) { cmd_to_run += " >> /tmp/ngps_acq.log 2>&1"; this->async.enqueue_and_log( function, "NOTICE: logging fine tune output to /tmp/ngps_acq.log" ); } diff --git a/sequencerd/sequence.h b/sequencerd/sequence.h index 267cf85c..92381314 100644 --- a/sequencerd/sequence.h +++ b/sequencerd/sequence.h @@ -319,7 +319,7 @@ namespace Sequencer { acquisition_max_retrys(-1), acq_automatic_mode(1), acq_fine_tune_cmd("ngps_acq"), - acq_fine_tune_xterm(false), + acq_fine_tune_log(false), acq_offset_settle(0), tcs_offsetrate_ra(45), tcs_offsetrate_dec(45), @@ -380,7 +380,7 @@ namespace Sequencer { int acquisition_max_retrys; ///< max number of acquisition loop attempts int acq_automatic_mode; ///< acquisition automation mode (1=legacy, 2=semi-auto, 3=auto) std::string acq_fine_tune_cmd; ///< fine-tune command to run after guiding - bool acq_fine_tune_xterm; ///< run fine-tune command in its own xterm + bool acq_fine_tune_log; ///< log fine-tune output to /tmp/ngps_acq.log double acq_offset_settle; ///< seconds to wait after automatic offset double tcs_offsetrate_ra; ///< TCS offset rate RA ("MRATE") in arcsec per second double tcs_offsetrate_dec; ///< TCS offset rate DEC ("MRATE") in arcsec per second diff --git a/sequencerd/sequencer_server.cpp b/sequencerd/sequencer_server.cpp index 4407225d..da9b0251 100644 --- a/sequencerd/sequencer_server.cpp +++ b/sequencerd/sequencer_server.cpp @@ -422,14 +422,14 @@ namespace Sequencer { applied++; } - // ACQ_FINE_TUNE_XTERM - if (config.param[entry] == "ACQ_FINE_TUNE_XTERM") { + // ACQ_FINE_TUNE_LOG + if (config.param[entry] == "ACQ_FINE_TUNE_LOG") { try { int val = std::stoi( config.arg[entry] ); - this->sequence.acq_fine_tune_xterm = ( val != 0 ); + this->sequence.acq_fine_tune_log = ( val != 0 ); } catch (const std::exception &e) { - message.str(""); message << "ERROR parsing ACQ_FINE_TUNE_XTERM: " << e.what(); + message.str(""); message << "ERROR parsing ACQ_FINE_TUNE_LOG: " << e.what(); this->sequence.async.enqueue_and_log( function, message.str() ); return ERROR; } From 144c9e1726892fb7c2686a410e97d87a8c1891e6 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sat, 7 Feb 2026 20:51:28 -0800 Subject: [PATCH 37/74] Add detailed exit code logging for fine-tune process - Log raw status, exit code, and signal termination info - Write exit status to both sequencer log and /tmp/ngps_acq.log - Helps diagnose why fine-tune appears to fail when it converges --- sequencerd/sequence.cpp | 55 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index c91ae2e2..10b52a69 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -705,13 +705,58 @@ namespace Sequencer { } this->fine_tune_pid.store( 0 ); - if ( WIFEXITED( status ) && WEXITSTATUS( status ) == 0 ) { - this->async.enqueue_and_log( function, "NOTICE: fine tune complete" ); - return NO_ERROR; + + // Log detailed exit status information + std::stringstream status_msg; + status_msg << "fine tune process exit status: raw=" << status; + + if ( WIFEXITED( status ) ) { + int exit_code = WEXITSTATUS( status ); + status_msg << " exited_normally=true exit_code=" << exit_code; + logwrite( function, status_msg.str() ); + + if ( this->acq_fine_tune_log ) { + // Also log to /tmp/ngps_acq.log for easy debugging + std::ofstream logfile("/tmp/ngps_acq.log", std::ios::app); + if ( logfile.is_open() ) { + logfile << "=== SEQUENCER: " << status_msg.str() << " ===" << std::endl; + logfile.close(); + } + } + + if ( exit_code == 0 ) { + this->async.enqueue_and_log( function, "NOTICE: fine tune complete" ); + return NO_ERROR; + } + else { + message.str(""); message << "ERROR: fine tune command exited with code " << exit_code; + this->async.enqueue_and_log( function, message.str() ); + return ERROR; + } } + else if ( WIFSIGNALED( status ) ) { + int signal = WTERMSIG( status ); + status_msg << " exited_normally=false terminated_by_signal=" << signal; + logwrite( function, status_msg.str() ); + + if ( this->acq_fine_tune_log ) { + std::ofstream logfile("/tmp/ngps_acq.log", std::ios::app); + if ( logfile.is_open() ) { + logfile << "=== SEQUENCER: " << status_msg.str() << " ===" << std::endl; + logfile.close(); + } + } - logwrite( function, "ERROR fine tune command failed: "+this->acq_fine_tune_cmd ); - return ERROR; + message.str(""); message << "ERROR: fine tune command terminated by signal " << signal; + this->async.enqueue_and_log( function, message.str() ); + return ERROR; + } + else { + status_msg << " unknown_exit_condition"; + logwrite( function, status_msg.str() ); + logwrite( function, "ERROR fine tune command failed: "+this->acq_fine_tune_cmd ); + return ERROR; + } }; if ( this->acq_automatic_mode == 1 ) { From 4cd1aa8c3e6e12be67b6bc1a551c59d813c3da6c Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sat, 7 Feb 2026 21:04:21 -0800 Subject: [PATCH 38/74] Add errno logging for waitpid failure in fine-tune - Log errno and strerror when waitpid returns error - Write error to both sequencer log and /tmp/ngps_acq.log - Helps diagnose why waitpid fails when ngps_acq completes successfully --- sequencerd/sequence.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index 10b52a69..80790f93 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -681,7 +681,17 @@ namespace Sequencer { pid_t result = waitpid( pid, &status, WNOHANG ); if ( result == pid ) break; if ( result < 0 ) { - logwrite( function, "ERROR waiting on fine tune command" ); + std::stringstream errmsg; + errmsg << "ERROR waiting on fine tune command: waitpid returned " << result + << " errno=" << errno << " (" << strerror(errno) << ")"; + logwrite( function, errmsg.str() ); + if ( this->acq_fine_tune_log ) { + std::ofstream logfile("/tmp/ngps_acq.log", std::ios::app); + if ( logfile.is_open() ) { + logfile << "=== SEQUENCER: " << errmsg.str() << " ===" << std::endl; + logfile.close(); + } + } return ERROR; } if ( this->cancel_flag.load() ) { From bb05eeaa1b3461dc3867f4a64840f017bfb208a4 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sat, 7 Feb 2026 21:04:34 -0800 Subject: [PATCH 39/74] Increase ACQUIRE_TIMEOUT from 90 to 120 seconds - Give more time for fine-tuning convergence - Fine-tune can take 70+ seconds with multiple cycles --- Config/sequencerd.cfg.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config/sequencerd.cfg.in b/Config/sequencerd.cfg.in index 5801f3a0..03653c9a 100644 --- a/Config/sequencerd.cfg.in +++ b/Config/sequencerd.cfg.in @@ -155,7 +155,7 @@ TCS_PREAUTH_TIME=10 # seconds before end of exposure to notify # ACAM Target acquisition # -ACQUIRE_TIMEOUT=90 # seconds before ACAM acquisition sequence aborts on failure to acquire (REQUIRED!) +ACQUIRE_TIMEOUT=120 # seconds before ACAM acquisition sequence aborts on failure to acquire (REQUIRED!) 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 From 7247afd4a51927c246645a65d68112c59c2b5209 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sat, 7 Feb 2026 21:18:15 -0800 Subject: [PATCH 40/74] Fix fine-tune waitpid failure by temporarily restoring SIGCHLD handler - Daemon sets SIGCHLD=SIG_IGN causing kernel to auto-reap children - This made waitpid fail with ECHILD (No child processes) - Temporarily restore SIG_DFL before fork, restore old handler after waitpid - Fixes 'fine tune failed' error when ngps_acq converges successfully - TODO: Find cleaner solution (double-fork, or use different process management) --- sequencerd/sequence.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index 80790f93..dba48067 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -661,6 +661,14 @@ namespace Sequencer { this->async.enqueue_and_log( function, "NOTICE: logging fine tune output to /tmp/ngps_acq.log" ); } + // Temporarily restore default SIGCHLD handling so we can waitpid() on this child. + // The daemon has SIGCHLD=SIG_IGN which causes kernel to auto-reap children. + struct sigaction old_action, new_action; + memset(&new_action, 0, sizeof(new_action)); + new_action.sa_handler = SIG_DFL; + sigemptyset(&new_action.sa_mask); + sigaction(SIGCHLD, &new_action, &old_action); + pid_t pid = fork(); if ( pid == 0 ) { // make a dedicated process group so we can signal the whole tree @@ -670,6 +678,7 @@ namespace Sequencer { } if ( pid < 0 ) { logwrite( function, "ERROR starting fine tune command: "+this->acq_fine_tune_cmd ); + sigaction(SIGCHLD, &old_action, nullptr); // Restore old handler return ERROR; } // Ensure the child is its own process group (best effort). @@ -692,6 +701,7 @@ namespace Sequencer { logfile.close(); } } + sigaction(SIGCHLD, &old_action, nullptr); // Restore old handler return ERROR; } if ( this->cancel_flag.load() ) { @@ -709,6 +719,7 @@ namespace Sequencer { waitpid( pid, &status, 0 ); } this->fine_tune_pid.store( 0 ); + sigaction(SIGCHLD, &old_action, nullptr); // Restore old handler return ERROR; } std::this_thread::sleep_for( std::chrono::milliseconds(100) ); @@ -716,6 +727,9 @@ namespace Sequencer { this->fine_tune_pid.store( 0 ); + // Restore old SIGCHLD handler now that we've reaped the child + sigaction(SIGCHLD, &old_action, nullptr); + // Log detailed exit status information std::stringstream status_msg; status_msg << "fine tune process exit status: raw=" << status; From a630f0f20f57a1772e2322338d9389402485ad2d Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sat, 7 Feb 2026 21:31:16 -0800 Subject: [PATCH 41/74] Fix fine-tune progress publishing for seq-progress GUI - Publish fine_tune_active=true immediately after fork sets fine_tune_pid - Publish fine_tune_active=false on all exit paths (success, error, signal) - Remove redundant publish calls outside run_fine_tune lambda - Works for both acqmode 2 and 3 (shared code path) --- sequencerd/sequence.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index dba48067..54666ee4 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -684,6 +684,7 @@ namespace Sequencer { // Ensure the child is its own process group (best effort). setpgid( pid, pid ); this->fine_tune_pid.store( pid ); + this->publish_progress(); // Publish fine_tune_active=true int status = 0; while ( true ) { @@ -750,11 +751,13 @@ namespace Sequencer { if ( exit_code == 0 ) { this->async.enqueue_and_log( function, "NOTICE: fine tune complete" ); + this->publish_progress(); // Publish fine_tune_active=false (success) return NO_ERROR; } else { message.str(""); message << "ERROR: fine tune command exited with code " << exit_code; this->async.enqueue_and_log( function, message.str() ); + this->publish_progress(); // Publish fine_tune_active=false (failure) return ERROR; } } @@ -773,12 +776,14 @@ namespace Sequencer { message.str(""); message << "ERROR: fine tune command terminated by signal " << signal; this->async.enqueue_and_log( function, message.str() ); + this->publish_progress(); // Publish fine_tune_active=false (terminated) return ERROR; } else { status_msg << " unknown_exit_condition"; logwrite( function, status_msg.str() ); logwrite( function, "ERROR fine tune command failed: "+this->acq_fine_tune_cmd ); + this->publish_progress(); // Publish fine_tune_active=false (unknown) return ERROR; } }; @@ -810,9 +815,7 @@ namespace Sequencer { } } else { - this->publish_progress(); // Publish before fine-tune (fine_tune_pid will be set inside run_fine_tune) bool fine_tune_ok = ( run_fine_tune() == NO_ERROR ); - this->publish_progress(); // Publish after fine-tune completes (fine_tune_pid will be 0) if ( !fine_tune_ok ) { this->async.enqueue_and_log( function, "WARNING: fine tune failed; waiting for USER continue to expose" ); if ( !wait_for_user( "waiting for USER to send \"continue\" signal to expose (fine tune failed)" ) ) { From 03bce89c586f39e874620cd43e85fd23a771e6f6 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sat, 7 Feb 2026 21:37:09 -0800 Subject: [PATCH 42/74] Add offset support to acqmode 2 with same behavior as acqmode 3 - acqmode 2 now applies offsets after fine-tune (with user wait) - acqmode 3 applies offsets automatically (no wait) - Offset logic, logging, and ZMQ publishing now identical between modes - Only difference: acqmode 2 has wait_for_user checkpoints --- sequencerd/sequence.cpp | 38 ++++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index 54666ee4..239d167c 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -825,30 +825,32 @@ namespace Sequencer { } if ( fine_tune_ok ) { + // acqmode 2: wait for user before offset if ( this->acq_automatic_mode == 2 ) { - if ( !wait_for_user( "waiting for USER to send \"continue\" signal to expose" ) ) { + if ( !wait_for_user( "waiting for USER to send \"continue\" signal to apply offset and expose" ) ) { this->async.enqueue_and_log( function, "NOTICE: sequence cancelled" ); return; } } - else if ( this->acq_automatic_mode == 3 ) { - if ( this->target.offset_ra != 0.0 || this->target.offset_dec != 0.0 ) { - this->async.enqueue_and_log( function, "NOTICE: applying target offset automatically" ); - this->offset_active.store(true); - this->publish_progress(); // Publish offset_active=true - error |= this->target_offset(); - this->offset_active.store(false); - if ( error != NO_ERROR ) { - this->thread_error_manager.set( THR_ACQUISITION ); - this->publish_progress(); // Publish with offset error state - return; - } - if ( this->acq_offset_settle > 0 ) { - this->async.enqueue_and_log( function, "NOTICE: waiting for offset settle time" ); - std::this_thread::sleep_for( std::chrono::duration( this->acq_offset_settle ) ); - } - this->publish_progress(); // Publish offset complete + + // Apply offset for both acqmode 2 and 3 + if ( this->target.offset_ra != 0.0 || this->target.offset_dec != 0.0 ) { + std::string mode_str = (this->acq_automatic_mode == 3 ? "automatically " : ""); + this->async.enqueue_and_log( function, "NOTICE: applying target offset " + mode_str ); + this->offset_active.store(true); + this->publish_progress(); // Publish offset_active=true + error |= this->target_offset(); + this->offset_active.store(false); + if ( error != NO_ERROR ) { + this->thread_error_manager.set( THR_ACQUISITION ); + this->publish_progress(); // Publish with offset error state + return; + } + if ( this->acq_offset_settle > 0 ) { + this->async.enqueue_and_log( function, "NOTICE: waiting for offset settle time" ); + std::this_thread::sleep_for( std::chrono::duration( this->acq_offset_settle ) ); } + this->publish_progress(); // Publish offset complete } } } From ccf763f19b725edd07720338c8da7ae8a5f890a6 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sat, 7 Feb 2026 21:47:45 -0800 Subject: [PATCH 43/74] Set ACQ_OFFSET_SETTLE to 3 seconds - Allow telescope to settle after applying offset - Improves target centering before exposure starts --- Config/sequencerd.cfg.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config/sequencerd.cfg.in b/Config/sequencerd.cfg.in index 03653c9a..9089771e 100644 --- a/Config/sequencerd.cfg.in +++ b/Config/sequencerd.cfg.in @@ -163,7 +163,7 @@ ACQUIRE_TCS_MAX_OFFSET=60 # the maximum allowable offset sent to the ACQ_AUTOMATIC_MODE=1 # 1=legacy, 2=semi-auto, 3=auto ACQ_FINE_TUNE_CMD=ngps_acq # command to run after guiding for final fine tune ACQ_FINE_TUNE_LOG=1 # log fine tune output to /tmp/ngps_acq.log (0/1) -ACQ_OFFSET_SETTLE=0 # seconds to wait after automatic offset +ACQ_OFFSET_SETTLE=3 # seconds to wait after automatic offset # Calibration Settings # CAL_TARGET=(name caldoor calcover U G R I lampthar lampfear lampbluc lampredc lolamp hilamp mod1 mod2 ... mod6) From 9065b3243f06d2513d495af8f827397878668561 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sat, 7 Feb 2026 23:07:22 -0800 Subject: [PATCH 44/74] Add offset value display to seq-progress GUI - Publish offset_ra and offset_dec in seq_progress topic - Display offset values above guiding indicator box - Format: "Offset: dRA=X.XX" dDEC=Y.YY"" - Updates in real-time from ZMQ messages --- common/message_keys.h | 2 ++ sequencerd/sequence.cpp | 4 ++++ utils/seq_progress_gui.cpp | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/common/message_keys.h b/common/message_keys.h index 521705d8..46156854 100644 --- a/common/message_keys.h +++ b/common/message_keys.h @@ -30,6 +30,8 @@ namespace Key { inline const std::string FINE_TUNE_ACTIVE = "fine_tune_active"; inline const std::string OFFSET_ACTIVE = "offset_active"; inline const std::string OFFSET_SETTLE = "offset_settle"; + inline const std::string OFFSET_RA = "offset_ra"; + inline const std::string OFFSET_DEC = "offset_dec"; inline const std::string OBSID = "obsid"; inline const std::string TARGET_STATE = "target_state"; inline const std::string EVENT = "event"; diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index 239d167c..4f602195 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -209,6 +209,10 @@ namespace Sequencer { jmessage_out["obsid"] = this->target.obsid; jmessage_out["target_state"] = this->target.state; + // Target offset values (arcsec) + jmessage_out["offset_ra"] = this->target.offset_ra; + jmessage_out["offset_dec"] = this->target.offset_dec; + try { this->publisher->publish( jmessage_out, "seq_progress" ); } diff --git a/utils/seq_progress_gui.cpp b/utils/seq_progress_gui.cpp index 5f60903b..f18cb4ad 100644 --- a/utils/seq_progress_gui.cpp +++ b/utils/seq_progress_gui.cpp @@ -87,6 +87,8 @@ struct SequenceState { std::chrono::steady_clock::time_point last_ontarget; int current_obsid = -1; std::string current_target_state; + double offset_ra = 0.0; + double offset_dec = 0.0; void reset() { for (int i = 0; i < kPhaseCount; ++i) { @@ -554,6 +556,7 @@ class SeqProgressGui { draw_status(); draw_buttons(); draw_ontarget_indicator(); + draw_offset_values(); draw_guiding_indicator(); draw_seqstatus_indicator(); @@ -658,6 +661,15 @@ class SeqProgressGui { XDrawString(display_, window_, gc_, 394, 164, label, std::strlen(label)); } + void draw_offset_values() { + // Display offset values above the guiding indicator box + char label[64]; + snprintf(label, sizeof(label), "Offset: dRA=%.2f\" dDEC=%.2f\"", + state_.offset_ra, state_.offset_dec); + XSetForeground(display_, gc_, color_text_); + XDrawString(display_, window_, gc_, guiding_box_.x, guiding_box_.y - 8, label, std::strlen(label)); + } + void draw_guiding_indicator() { const char *label = state_.guiding_on ? "GUIDING ON" : "GUIDING OFF"; unsigned long fill = state_.guiding_on ? color_complete_ : color_wait_; @@ -829,6 +841,12 @@ class SeqProgressGui { state_.phase_complete[PHASE_OFFSET] = true; clear_phase_active(PHASE_OFFSET); } + if (jmessage.contains("offset_ra") && jmessage["offset_ra"].is_number()) { + state_.offset_ra = jmessage["offset_ra"].get(); + } + if (jmessage.contains("offset_dec") && jmessage["offset_dec"].is_number()) { + state_.offset_dec = jmessage["offset_dec"].get(); + } } else if (topic == "acamd") { if (jmessage.contains("ACAM_GUIDING") && jmessage["ACAM_GUIDING"].is_boolean()) { state_.guiding_on = jmessage["ACAM_GUIDING"].get(); From 7655d9de5959b235a279b034874a48913da17e34 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sat, 7 Feb 2026 23:45:03 -0800 Subject: [PATCH 45/74] Track and display multi-exposure (NEXP>1) progress in seq-progress GUI Sequencer changes: - Publish nexp in seq_progress topic - Parse FRAMECOUNT messages and republish current_frame via ZMQ - Allows GUI to track frame progress without UDP listener GUI changes: - Track nexp, current_frame, and max_frame_seen in state - Keep EXPOSE phase active until all frames complete - Display 'EXPOSURE X/Y' for NEXP>1 - Display 'EXPOSURE X/Y (elapsed/total s)' with timing info - Reset frame tracking on new target (nexp change) Fixes issue where seq-progress showed empty progress bar after first exposure completes when NEXP>1, while system continues exposing. --- sequencerd/sequence.cpp | 22 +++++++++++++++++++++ utils/seq_progress_gui.cpp | 40 +++++++++++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index 4f602195..e8f879ef 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -213,6 +213,9 @@ namespace Sequencer { jmessage_out["offset_ra"] = this->target.offset_ra; jmessage_out["offset_dec"] = this->target.offset_dec; + // Number of exposures for this target + jmessage_out["nexp"] = this->target.nexp; + try { this->publisher->publish( jmessage_out, "seq_progress" ); } @@ -366,12 +369,31 @@ namespace Sequencer { // --------------------------------------------- // clear READOUT flag on the end-of-frame signal + // Parse and publish frame count for seq-progress GUI // --------------------------------------------- // if ( statstr.compare( 0, 10, "FRAMECOUNT" ) == 0 ) { // async message tag FRAMECOUNT if ( seq.wait_state_manager.is_set( Sequencer::SEQ_WAIT_READOUT ) ) { seq.wait_state_manager.clear( Sequencer::SEQ_WAIT_READOUT ); } + // Parse frame number from "FRAMECOUNT_: rows=X cols=Y" + size_t colon_pos = statstr.find(':'); + if (colon_pos != std::string::npos) { + size_t space_pos = statstr.find(' ', colon_pos); + try { + std::string frame_str = statstr.substr(colon_pos + 1, + space_pos == std::string::npos ? std::string::npos : space_pos - colon_pos - 1); + int framenum = std::stoi(frame_str); + // Publish frame count to seq_progress topic + nlohmann::json jmessage; + jmessage["source"] = Sequencer::DAEMON_NAME; + jmessage["current_frame"] = framenum; + jmessage["nexp"] = seq.target.nexp; + seq.publisher->publish(jmessage, "seq_progress"); + } catch (...) { + // Ignore parse errors + } + } } // --------------------- diff --git a/utils/seq_progress_gui.cpp b/utils/seq_progress_gui.cpp index f18cb4ad..1228dfd3 100644 --- a/utils/seq_progress_gui.cpp +++ b/utils/seq_progress_gui.cpp @@ -89,6 +89,9 @@ struct SequenceState { std::string current_target_state; double offset_ra = 0.0; double offset_dec = 0.0; + int nexp = 1; + int current_frame = 0; + int max_frame_seen = 0; void reset() { for (int i = 0; i < kPhaseCount; ++i) { @@ -111,6 +114,9 @@ struct SequenceState { waitstate.clear(); current_obsid = -1; current_target_state.clear(); + nexp = 1; + current_frame = 0; + max_frame_seen = 0; } void reset_progress_only() { @@ -620,7 +626,15 @@ class SeqProgressGui { status = "APPLYING OFFSET"; } else if (state_.phase_active[PHASE_EXPOSE]) { std::ostringstream oss; - if (state_.exposure_total > 0.0) { + // Show frame count if NEXP > 1 + if (state_.nexp > 1) { + oss << "EXPOSURE " << state_.current_frame << " / " << state_.nexp; + if (state_.exposure_total > 0.0) { + oss.setf(std::ios::fixed); + oss.precision(1); + oss << " (" << state_.exposure_elapsed << " / " << state_.exposure_total << " s)"; + } + } else if (state_.exposure_total > 0.0) { oss.setf(std::ios::fixed); oss.precision(1); oss << "EXPOSURE " << state_.exposure_elapsed << " / " << state_.exposure_total << " s"; @@ -847,6 +861,30 @@ class SeqProgressGui { if (jmessage.contains("offset_dec") && jmessage["offset_dec"].is_number()) { state_.offset_dec = jmessage["offset_dec"].get(); } + if (jmessage.contains("nexp") && jmessage["nexp"].is_number()) { + int new_nexp = jmessage["nexp"].get(); + if (new_nexp != state_.nexp) { + state_.nexp = new_nexp; + // Reset frame tracking when nexp changes + state_.current_frame = 0; + state_.max_frame_seen = 0; + } + } + if (jmessage.contains("current_frame") && jmessage["current_frame"].is_number()) { + int frame = jmessage["current_frame"].get(); + if (frame > state_.max_frame_seen) { + state_.max_frame_seen = frame; + state_.current_frame = frame; + // Keep EXPOSE phase active if more frames expected + if (state_.current_frame < state_.nexp) { + set_phase(PHASE_EXPOSE); + } else if (state_.current_frame >= state_.nexp) { + // All frames complete + state_.phase_complete[PHASE_EXPOSE] = true; + clear_phase_active(PHASE_EXPOSE); + } + } + } } else if (topic == "acamd") { if (jmessage.contains("ACAM_GUIDING") && jmessage["ACAM_GUIDING"].is_boolean()) { state_.guiding_on = jmessage["ACAM_GUIDING"].get(); From 15a886570a84c09323cae4fe1f71465f59986f51 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sun, 8 Feb 2026 00:11:58 -0800 Subject: [PATCH 46/74] Move ngps_acq log to data directory with date stamp - Use get_latest_datedir() to construct log filename - Format: /data/YYYYMMDD/logs/ngps_acq_YYYYMMDD.log - Matches daemon logging pattern (sequencerd_YYYYMMDD.log, etc.) - All output from same night appends to same file - Allows for later success/fail statistics analysis - Replaces /tmp/ngps_acq.log with proper dated logs --- sequencerd/sequence.cpp | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index e8f879ef..4b7eb432 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -680,11 +680,17 @@ namespace Sequencer { if ( this->acq_fine_tune_cmd.empty() ) return NO_ERROR; this->async.enqueue_and_log( function, "NOTICE: running fine tune command: "+this->acq_fine_tune_cmd ); + // Construct log filename using same pattern as daemon logs: /data/{datedir}/logs/ngps_acq_{datedir}.log + std::string datedir = get_latest_datedir( "/data" ); + std::stringstream logfilename; + logfilename << "/data/" << datedir << "/logs/ngps_acq_" << datedir << ".log"; + std::string acq_logfile = logfilename.str(); + // Build command with optional logging redirection std::string cmd_to_run = this->acq_fine_tune_cmd; if ( this->acq_fine_tune_log ) { - cmd_to_run += " >> /tmp/ngps_acq.log 2>&1"; - this->async.enqueue_and_log( function, "NOTICE: logging fine tune output to /tmp/ngps_acq.log" ); + cmd_to_run += " >> " + acq_logfile + " 2>&1"; + this->async.enqueue_and_log( function, "NOTICE: logging fine tune output to "+acq_logfile ); } // Temporarily restore default SIGCHLD handling so we can waitpid() on this child. @@ -722,7 +728,7 @@ namespace Sequencer { << " errno=" << errno << " (" << strerror(errno) << ")"; logwrite( function, errmsg.str() ); if ( this->acq_fine_tune_log ) { - std::ofstream logfile("/tmp/ngps_acq.log", std::ios::app); + std::ofstream logfile(acq_logfile, std::ios::app); if ( logfile.is_open() ) { logfile << "=== SEQUENCER: " << errmsg.str() << " ===" << std::endl; logfile.close(); @@ -767,8 +773,7 @@ namespace Sequencer { logwrite( function, status_msg.str() ); if ( this->acq_fine_tune_log ) { - // Also log to /tmp/ngps_acq.log for easy debugging - std::ofstream logfile("/tmp/ngps_acq.log", std::ios::app); + std::ofstream logfile(acq_logfile, std::ios::app); if ( logfile.is_open() ) { logfile << "=== SEQUENCER: " << status_msg.str() << " ===" << std::endl; logfile.close(); @@ -793,7 +798,7 @@ namespace Sequencer { logwrite( function, status_msg.str() ); if ( this->acq_fine_tune_log ) { - std::ofstream logfile("/tmp/ngps_acq.log", std::ios::app); + std::ofstream logfile(acq_logfile, std::ios::app); if ( logfile.is_open() ) { logfile << "=== SEQUENCER: " << status_msg.str() << " ===" << std::endl; logfile.close(); From 433c0e3d14de3e3093e7cdb1402cc7b3ca5c28e4 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sun, 8 Feb 2026 00:15:15 -0800 Subject: [PATCH 47/74] Refine seq-progress exposure and offset display - Combine exposure percent with timing: 'EXPOSURE elapsed/total s X%' - Remove redundant 'EXPOSURE X%' fallback - Rename offset labels: 'dRA/dDEC' -> 'RA/DEC' to avoid confusion with non-sidereal tracking rates - Display: 'Offset: RA=X.XX" DEC=Y.YY"' --- utils/seq_progress_gui.cpp | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/utils/seq_progress_gui.cpp b/utils/seq_progress_gui.cpp index 1228dfd3..e4cf3345 100644 --- a/utils/seq_progress_gui.cpp +++ b/utils/seq_progress_gui.cpp @@ -634,12 +634,16 @@ class SeqProgressGui { oss.precision(1); oss << " (" << state_.exposure_elapsed << " / " << state_.exposure_total << " s)"; } - } else if (state_.exposure_total > 0.0) { - oss.setf(std::ios::fixed); - oss.precision(1); - oss << "EXPOSURE " << state_.exposure_elapsed << " / " << state_.exposure_total << " s"; } else { - oss << "EXPOSURE " << static_cast(state_.exposure_progress * 100.0) << "%"; + oss << "EXPOSURE"; + if (state_.exposure_total > 0.0) { + oss.setf(std::ios::fixed); + oss.precision(1); + oss << " " << state_.exposure_elapsed << " / " << state_.exposure_total << " s"; + } + if (state_.exposure_progress > 0.0) { + oss << " " << static_cast(state_.exposure_progress * 100.0) << "%"; + } } status = oss.str(); } @@ -678,7 +682,7 @@ class SeqProgressGui { void draw_offset_values() { // Display offset values above the guiding indicator box char label[64]; - snprintf(label, sizeof(label), "Offset: dRA=%.2f\" dDEC=%.2f\"", + snprintf(label, sizeof(label), "Offset: RA=%.2f\" DEC=%.2f\"", state_.offset_ra, state_.offset_dec); XSetForeground(display_, gc_, color_text_); XDrawString(display_, window_, gc_, guiding_box_.x, guiding_box_.y - 8, label, std::strlen(label)); From 59edca37c0ce382010807042421c76cf121a3118 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sun, 8 Feb 2026 00:31:10 -0800 Subject: [PATCH 48/74] Fix exposure progress parsing and remove duplicate status - Parse EXPTIME messages: format is 'EXPTIME:remaining total frame' - Calculate elapsed = total - remaining (ms) - Update exposure_progress percentage from EXPTIME updates - Remove duplicate status label below progress bar (redundant with phase labels) - Keep ELAPSEDTIME parsing as fallback for compatibility --- utils/seq_progress_gui.cpp | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/utils/seq_progress_gui.cpp b/utils/seq_progress_gui.cpp index e4cf3345..b22896fb 100644 --- a/utils/seq_progress_gui.cpp +++ b/utils/seq_progress_gui.cpp @@ -559,7 +559,6 @@ class SeqProgressGui { draw_title(); draw_bar(); draw_labels(); - draw_status(); draw_buttons(); draw_ontarget_indicator(); draw_offset_values(); @@ -1099,6 +1098,24 @@ class SeqProgressGui { } } else if (starts_with_local(msg, "WAITSTATE:")) { handle_waitstate(trim_copy(msg.substr(10))); + } else if (starts_with_local(msg, "EXPTIME:")) { + // Parse EXPTIME:remaining total frame + auto parts = split_ws(msg.substr(8)); // Skip "EXPTIME:" + if (parts.size() >= 2) { + try { + int remaining_ms = std::stoi(parts[0]); + int total_ms = std::stoi(parts[1]); + if (total_ms > 0) { + int elapsed_ms = total_ms - remaining_ms; + state_.exposure_elapsed = elapsed_ms / 1000.0; + state_.exposure_total = total_ms / 1000.0; + state_.exposure_progress = std::min(1.0, static_cast(elapsed_ms) / static_cast(total_ms)); + set_phase(PHASE_EXPOSE); + } + } catch (...) { + // Ignore parse errors + } + } } else if (starts_with_local(msg, "ELAPSEDTIME")) { auto parts = split_ws(msg); if (parts.size() >= 2) { From 450bfdd1e9d8e0570fc9e9a0e2588afd0dda982f Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sun, 8 Feb 2026 00:33:17 -0800 Subject: [PATCH 49/74] Fix EXPTIME percentage parsing - use provided percent value - EXPTIME format is 'EXPTIME:remaining total percent' - Third field is the percentage (already averaged across cameras) - Use it directly: exposure_progress = percent / 100.0 - Much simpler than calculating from elapsed/total --- utils/seq_progress_gui.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/utils/seq_progress_gui.cpp b/utils/seq_progress_gui.cpp index b22896fb..86146f35 100644 --- a/utils/seq_progress_gui.cpp +++ b/utils/seq_progress_gui.cpp @@ -1099,17 +1099,19 @@ class SeqProgressGui { } else if (starts_with_local(msg, "WAITSTATE:")) { handle_waitstate(trim_copy(msg.substr(10))); } else if (starts_with_local(msg, "EXPTIME:")) { - // Parse EXPTIME:remaining total frame + // Parse EXPTIME:remaining total percent auto parts = split_ws(msg.substr(8)); // Skip "EXPTIME:" - if (parts.size() >= 2) { + if (parts.size() >= 3) { try { int remaining_ms = std::stoi(parts[0]); int total_ms = std::stoi(parts[1]); + int percent = std::stoi(parts[2]); if (total_ms > 0) { int elapsed_ms = total_ms - remaining_ms; state_.exposure_elapsed = elapsed_ms / 1000.0; state_.exposure_total = total_ms / 1000.0; - state_.exposure_progress = std::min(1.0, static_cast(elapsed_ms) / static_cast(total_ms)); + // Use the percentage directly from camerad (already averaged across cameras) + state_.exposure_progress = std::min(1.0, percent / 100.0); set_phase(PHASE_EXPOSE); } } catch (...) { From 51c55a1704b4db5ec898d1a484bdb1d4851696a5 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sun, 8 Feb 2026 00:33:42 -0800 Subject: [PATCH 50/74] Add smoothing to exposure progress percentage - Use exponential moving average (70% old, 30% new) - Prevents rapid jumps when different cameras report different percentages - E.g., if cameras report 75% and 80%, value smoothly transitions - First value used directly to initialize --- utils/seq_progress_gui.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/utils/seq_progress_gui.cpp b/utils/seq_progress_gui.cpp index 86146f35..ecba5c3b 100644 --- a/utils/seq_progress_gui.cpp +++ b/utils/seq_progress_gui.cpp @@ -1110,8 +1110,14 @@ class SeqProgressGui { int elapsed_ms = total_ms - remaining_ms; state_.exposure_elapsed = elapsed_ms / 1000.0; state_.exposure_total = total_ms / 1000.0; - // Use the percentage directly from camerad (already averaged across cameras) - state_.exposure_progress = std::min(1.0, percent / 100.0); + // Smooth the percentage (exponential moving average to handle multiple cameras) + // Prevents jumps when different cameras report slightly different percentages + double new_progress = std::min(1.0, percent / 100.0); + if (state_.exposure_progress > 0.0) { + state_.exposure_progress = 0.7 * state_.exposure_progress + 0.3 * new_progress; + } else { + state_.exposure_progress = new_progress; + } set_phase(PHASE_EXPOSE); } } catch (...) { From 064ec31e53c25bba0b67262d0275dfd39c209c7c Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sun, 8 Feb 2026 00:37:21 -0800 Subject: [PATCH 51/74] Add exposure percentage to EXPOSURE label under progress bar - Shows 'EXPOSURE X%' directly on the phase label - Only displays when EXPOSE phase is active and progress > 0 - Replaces removed status line - progress info now in phase label - Updates live as percentage changes --- utils/seq_progress_gui.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/utils/seq_progress_gui.cpp b/utils/seq_progress_gui.cpp index ecba5c3b..1f5d19ba 100644 --- a/utils/seq_progress_gui.cpp +++ b/utils/seq_progress_gui.cpp @@ -605,7 +605,15 @@ class SeqProgressGui { int label_y = bar_.y + bar_.h + 16; for (int i = 0; i < kPhaseCount; ++i) { int tx = segments_[i].x + 6; - XDrawString(display_, window_, gc_, tx, label_y, labels[i], std::strlen(labels[i])); + // Add percentage to EXPOSURE label when active + if (i == PHASE_EXPOSE && state_.phase_active[PHASE_EXPOSE] && state_.exposure_progress > 0.0) { + char exp_label[64]; + int percent = static_cast(state_.exposure_progress * 100.0); + snprintf(exp_label, sizeof(exp_label), "EXPOSURE %d%%", percent); + XDrawString(display_, window_, gc_, tx, label_y, exp_label, std::strlen(exp_label)); + } else { + XDrawString(display_, window_, gc_, tx, label_y, labels[i], std::strlen(labels[i])); + } } } From 6e1b616407ea2ad78e10febcbfb06efaddb8d96b Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sun, 8 Feb 2026 00:39:31 -0800 Subject: [PATCH 52/74] Show exposure progress even at 0% and add frame count - Remove exposure_progress > 0.0 check so percentage always shows - Show 'EXPOSURE 0%' immediately when phase becomes active - Add frame count for NEXP>1: 'EXPOSURE 2/3 12%' - Ensures percentage is visible even when starting mid-exposure --- utils/seq_progress_gui.cpp | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/utils/seq_progress_gui.cpp b/utils/seq_progress_gui.cpp index 1f5d19ba..f2e54b6a 100644 --- a/utils/seq_progress_gui.cpp +++ b/utils/seq_progress_gui.cpp @@ -605,11 +605,16 @@ class SeqProgressGui { int label_y = bar_.y + bar_.h + 16; for (int i = 0; i < kPhaseCount; ++i) { int tx = segments_[i].x + 6; - // Add percentage to EXPOSURE label when active - if (i == PHASE_EXPOSE && state_.phase_active[PHASE_EXPOSE] && state_.exposure_progress > 0.0) { + // Add info to EXPOSURE label when active + if (i == PHASE_EXPOSE && state_.phase_active[PHASE_EXPOSE]) { char exp_label[64]; int percent = static_cast(state_.exposure_progress * 100.0); - snprintf(exp_label, sizeof(exp_label), "EXPOSURE %d%%", percent); + if (state_.nexp > 1) { + snprintf(exp_label, sizeof(exp_label), "EXPOSURE %d/%d %d%%", + state_.current_frame, state_.nexp, percent); + } else { + snprintf(exp_label, sizeof(exp_label), "EXPOSURE %d%%", percent); + } XDrawString(display_, window_, gc_, tx, label_y, exp_label, std::strlen(exp_label)); } else { XDrawString(display_, window_, gc_, tx, label_y, labels[i], std::strlen(labels[i])); From 2373b2d4028556c8cde6792cea9c738986d22bc5 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sun, 8 Feb 2026 00:50:11 -0800 Subject: [PATCH 53/74] Add debug logging for EXPTIME message parsing - Print received EXPTIME values to stderr - Show exposure_progress calculation - Help diagnose why percentage stays at 0% - Temporary debug output to be removed once issue resolved --- utils/seq_progress_gui.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/utils/seq_progress_gui.cpp b/utils/seq_progress_gui.cpp index f2e54b6a..04a6e3ff 100644 --- a/utils/seq_progress_gui.cpp +++ b/utils/seq_progress_gui.cpp @@ -1119,6 +1119,8 @@ class SeqProgressGui { int remaining_ms = std::stoi(parts[0]); int total_ms = std::stoi(parts[1]); int percent = std::stoi(parts[2]); + std::cerr << "DEBUG EXPTIME: remaining=" << remaining_ms << " total=" << total_ms + << " percent=" << percent << "\n"; if (total_ms > 0) { int elapsed_ms = total_ms - remaining_ms; state_.exposure_elapsed = elapsed_ms / 1000.0; @@ -1131,11 +1133,15 @@ class SeqProgressGui { } else { state_.exposure_progress = new_progress; } + std::cerr << "DEBUG exposure_progress=" << state_.exposure_progress + << " new_progress=" << new_progress << "\n"; set_phase(PHASE_EXPOSE); } - } catch (...) { - // Ignore parse errors + } catch (const std::exception &e) { + std::cerr << "DEBUG EXPTIME parse error: " << e.what() << "\n"; } + } else { + std::cerr << "DEBUG EXPTIME: Not enough parts, size=" << parts.size() << "\n"; } } else if (starts_with_local(msg, "ELAPSEDTIME")) { auto parts = split_ws(msg); From af3843d7325ab9d5f3c5a13d7063357e9d277d86 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sun, 8 Feb 2026 00:50:57 -0800 Subject: [PATCH 54/74] Remove exposure percentage display, keep frame count only - Remove percentage display (UDP EXPTIME not reliably received) - Show 'EXPOSURE X/Y' for NEXP>1 - Show 'EXPOSURE' for NEXP=1 - Keep EXPTIME parsing code for future use - Remove debug logging --- utils/seq_progress_gui.cpp | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/utils/seq_progress_gui.cpp b/utils/seq_progress_gui.cpp index 04a6e3ff..2e59881b 100644 --- a/utils/seq_progress_gui.cpp +++ b/utils/seq_progress_gui.cpp @@ -605,16 +605,11 @@ class SeqProgressGui { int label_y = bar_.y + bar_.h + 16; for (int i = 0; i < kPhaseCount; ++i) { int tx = segments_[i].x + 6; - // Add info to EXPOSURE label when active - if (i == PHASE_EXPOSE && state_.phase_active[PHASE_EXPOSE]) { + // Add frame count to EXPOSURE label when active and NEXP > 1 + if (i == PHASE_EXPOSE && state_.phase_active[PHASE_EXPOSE] && state_.nexp > 1) { char exp_label[64]; - int percent = static_cast(state_.exposure_progress * 100.0); - if (state_.nexp > 1) { - snprintf(exp_label, sizeof(exp_label), "EXPOSURE %d/%d %d%%", - state_.current_frame, state_.nexp, percent); - } else { - snprintf(exp_label, sizeof(exp_label), "EXPOSURE %d%%", percent); - } + snprintf(exp_label, sizeof(exp_label), "EXPOSURE %d/%d", + state_.current_frame, state_.nexp); XDrawString(display_, window_, gc_, tx, label_y, exp_label, std::strlen(exp_label)); } else { XDrawString(display_, window_, gc_, tx, label_y, labels[i], std::strlen(labels[i])); @@ -1112,36 +1107,29 @@ class SeqProgressGui { } else if (starts_with_local(msg, "WAITSTATE:")) { handle_waitstate(trim_copy(msg.substr(10))); } else if (starts_with_local(msg, "EXPTIME:")) { - // Parse EXPTIME:remaining total percent + // Parse EXPTIME:remaining total percent (for future use) auto parts = split_ws(msg.substr(8)); // Skip "EXPTIME:" if (parts.size() >= 3) { try { int remaining_ms = std::stoi(parts[0]); int total_ms = std::stoi(parts[1]); int percent = std::stoi(parts[2]); - std::cerr << "DEBUG EXPTIME: remaining=" << remaining_ms << " total=" << total_ms - << " percent=" << percent << "\n"; if (total_ms > 0) { int elapsed_ms = total_ms - remaining_ms; state_.exposure_elapsed = elapsed_ms / 1000.0; state_.exposure_total = total_ms / 1000.0; // Smooth the percentage (exponential moving average to handle multiple cameras) - // Prevents jumps when different cameras report slightly different percentages double new_progress = std::min(1.0, percent / 100.0); if (state_.exposure_progress > 0.0) { state_.exposure_progress = 0.7 * state_.exposure_progress + 0.3 * new_progress; } else { state_.exposure_progress = new_progress; } - std::cerr << "DEBUG exposure_progress=" << state_.exposure_progress - << " new_progress=" << new_progress << "\n"; set_phase(PHASE_EXPOSE); } - } catch (const std::exception &e) { - std::cerr << "DEBUG EXPTIME parse error: " << e.what() << "\n"; + } catch (...) { + // Ignore parse errors } - } else { - std::cerr << "DEBUG EXPTIME: Not enough parts, size=" << parts.size() << "\n"; } } else if (starts_with_local(msg, "ELAPSEDTIME")) { auto parts = split_ws(msg); From df1a98c0f6a712731244fdd5aeb16fe59ebdf58c Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sun, 8 Feb 2026 01:00:15 -0800 Subject: [PATCH 55/74] Re-enable exposure percentage with extensive debug logging - Show percentage on EXPOSURE label again - Debug: Print EXPTIME messages received via UDP - Debug: Print parsed values (remaining, total, percent) - Debug: Print exposure_progress after update - Debug: Print label being drawn every 10 frames - Will help diagnose if UDP is received but not parsed/displayed --- utils/seq_progress_gui.cpp | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/utils/seq_progress_gui.cpp b/utils/seq_progress_gui.cpp index 2e59881b..0582d0ad 100644 --- a/utils/seq_progress_gui.cpp +++ b/utils/seq_progress_gui.cpp @@ -376,6 +376,9 @@ class SeqProgressGui { if (ufd >= 0 && FD_ISSET(ufd, &fds)) { std::string msg; udp_.Receive(msg); + if (msg.find("EXPTIME:") != std::string::npos) { + std::cerr << "DEBUG UDP received: " << msg.substr(0, 80) << "\n"; + } handle_message(msg); need_redraw = true; } @@ -605,11 +608,21 @@ class SeqProgressGui { int label_y = bar_.y + bar_.h + 16; for (int i = 0; i < kPhaseCount; ++i) { int tx = segments_[i].x + 6; - // Add frame count to EXPOSURE label when active and NEXP > 1 - if (i == PHASE_EXPOSE && state_.phase_active[PHASE_EXPOSE] && state_.nexp > 1) { + // Add info to EXPOSURE label when active + if (i == PHASE_EXPOSE && state_.phase_active[PHASE_EXPOSE]) { char exp_label[64]; - snprintf(exp_label, sizeof(exp_label), "EXPOSURE %d/%d", - state_.current_frame, state_.nexp); + int percent = static_cast(state_.exposure_progress * 100.0); + if (state_.nexp > 1) { + snprintf(exp_label, sizeof(exp_label), "EXPOSURE %d/%d %d%%", + state_.current_frame, state_.nexp, percent); + } else { + snprintf(exp_label, sizeof(exp_label), "EXPOSURE %d%%", percent); + } + static int debug_counter = 0; + if (++debug_counter % 10 == 0) { // Print every 10th frame to avoid spam + std::cerr << "DEBUG drawing label: " << exp_label + << " (exposure_progress=" << state_.exposure_progress << ")\n"; + } XDrawString(display_, window_, gc_, tx, label_y, exp_label, std::strlen(exp_label)); } else { XDrawString(display_, window_, gc_, tx, label_y, labels[i], std::strlen(labels[i])); @@ -1107,13 +1120,16 @@ class SeqProgressGui { } else if (starts_with_local(msg, "WAITSTATE:")) { handle_waitstate(trim_copy(msg.substr(10))); } else if (starts_with_local(msg, "EXPTIME:")) { - // Parse EXPTIME:remaining total percent (for future use) + // Parse EXPTIME:remaining total percent auto parts = split_ws(msg.substr(8)); // Skip "EXPTIME:" + std::cerr << "DEBUG EXPTIME parsing, parts.size=" << parts.size() << "\n"; if (parts.size() >= 3) { try { int remaining_ms = std::stoi(parts[0]); int total_ms = std::stoi(parts[1]); int percent = std::stoi(parts[2]); + std::cerr << "DEBUG EXPTIME parsed: remaining=" << remaining_ms + << " total=" << total_ms << " percent=" << percent << "\n"; if (total_ms > 0) { int elapsed_ms = total_ms - remaining_ms; state_.exposure_elapsed = elapsed_ms / 1000.0; @@ -1125,10 +1141,12 @@ class SeqProgressGui { } else { state_.exposure_progress = new_progress; } + std::cerr << "DEBUG exposure_progress now: " << state_.exposure_progress + << " (from percent=" << percent << ")\n"; set_phase(PHASE_EXPOSE); } - } catch (...) { - // Ignore parse errors + } catch (const std::exception &e) { + std::cerr << "DEBUG EXPTIME parse exception: " << e.what() << "\n"; } } } else if (starts_with_local(msg, "ELAPSEDTIME")) { From 055e0750f93cadb021b52f9702c854f36ec61980 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sun, 8 Feb 2026 01:04:07 -0800 Subject: [PATCH 56/74] Add debug logging for UDP listener initialization - Print whether UDP listener will be started - Print msgport and msggroup from config - Print UDP listener file descriptor if started - Help diagnose why EXPTIME messages not received --- utils/seq_progress_gui.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/utils/seq_progress_gui.cpp b/utils/seq_progress_gui.cpp index 0582d0ad..c901ef3b 100644 --- a/utils/seq_progress_gui.cpp +++ b/utils/seq_progress_gui.cpp @@ -326,12 +326,18 @@ class SeqProgressGui { compute_layout(); bool use_udp = options_.sub_endpoint.empty(); + std::cerr << "DEBUG: use_udp=" << use_udp << " msgport=" << options_.msgport + << " msggroup=" << options_.msggroup << "\n"; if (use_udp && options_.msgport > 0 && !options_.msggroup.empty() && to_upper_copy(options_.msggroup) != "NONE") { udp_fd_ = udp_.Listener(); if (udp_fd_ < 0) { std::cerr << "ERROR starting UDP listener\n"; return false; } + std::cerr << "DEBUG: UDP listener started on port " << options_.msgport + << " group " << options_.msggroup << " fd=" << udp_fd_ << "\n"; + } else { + std::cerr << "DEBUG: UDP listener NOT started\n"; } init_zmq(); From cf43d95d9d4b35b0847581d4704f13d3ba2efed9 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sun, 8 Feb 2026 01:12:19 -0800 Subject: [PATCH 57/74] Republish EXPTIME to ZMQ for seq-progress (ZMQ-only mode) Sequencer: - Parse EXPTIME UDP messages and republish to seq_progress ZMQ topic - Rate-limit: only publish when percent changes (reduces message spam) - Format: exptime_remaining_ms, exptime_total_ms, exptime_percent GUI: - Receive exptime_percent from seq_progress ZMQ topic - Apply smoothing (70% old, 30% new) to percentage - Works when UDP listener disabled (use_udp=0, msggroup=NONE) - Fixes issue where GUI shows 0% during entire exposure --- sequencerd/sequence.cpp | 30 ++++++++++++++++++++++++++++++ utils/seq_progress_gui.cpp | 11 +++++++++++ 2 files changed, 41 insertions(+) diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index 4b7eb432..df0fd536 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -357,6 +357,36 @@ namespace Sequencer { } } + // -------------------------------------------------------------- + // Republish EXPTIME messages to ZMQ for seq-progress GUI + // Parse EXPTIME:remaining total percent and publish to seq_progress + // Rate-limited: only publish when percent changes to reduce message spam + // -------------------------------------------------------------- + // + if ( statstr.compare( 0, 8, "EXPTIME:" ) == 0 ) { // async message tag EXPTIME: + // Parse "EXPTIME:remaining total percent" + std::string vals = statstr.substr(8); // Skip "EXPTIME:" + std::istringstream iss(vals); + int remaining, total, percent; + if (iss >> remaining >> total >> percent) { + static int last_published_percent = -1; + // Only publish when percentage changes (rate limiting) + if (percent != last_published_percent) { + nlohmann::json jmessage; + jmessage["source"] = Sequencer::DAEMON_NAME; + jmessage["exptime_remaining_ms"] = remaining; + jmessage["exptime_total_ms"] = total; + jmessage["exptime_percent"] = percent; + try { + seq.publisher->publish( jmessage, "seq_progress" ); + last_published_percent = percent; + } catch (...) { + // Ignore publish errors + } + } + } + } + // ------------------------------------------------------------------ // Set READOUT flag and clear EXPOSE flag when pixels start coming in // ------------------------------------------------------------------ diff --git a/utils/seq_progress_gui.cpp b/utils/seq_progress_gui.cpp index c901ef3b..bf6d68d1 100644 --- a/utils/seq_progress_gui.cpp +++ b/utils/seq_progress_gui.cpp @@ -915,6 +915,17 @@ class SeqProgressGui { } } } + if (jmessage.contains("exptime_percent") && jmessage["exptime_percent"].is_number()) { + int percent = jmessage["exptime_percent"].get(); + double new_progress = std::min(1.0, percent / 100.0); + // Smooth the percentage (exponential moving average) + if (state_.exposure_progress > 0.0) { + state_.exposure_progress = 0.7 * state_.exposure_progress + 0.3 * new_progress; + } else { + state_.exposure_progress = new_progress; + } + set_phase(PHASE_EXPOSE); + } } else if (topic == "acamd") { if (jmessage.contains("ACAM_GUIDING") && jmessage["ACAM_GUIDING"].is_boolean()) { state_.guiding_on = jmessage["ACAM_GUIDING"].get(); From 7d7cd3fd8cb7c2b2e2e9902d7d0f500e38b8bec6 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sun, 8 Feb 2026 01:16:28 -0800 Subject: [PATCH 58/74] Hard-code NGPS_ROOT in seq-progress script - Set NGPS_ROOT=/home/developer/Software directly - Remove environment variable fallback logic - Simplifies launch: just run 'seq-progress' without setting NGPS_ROOT --- run/seq-progress | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/run/seq-progress b/run/seq-progress index 8a97d330..967e69ba 100755 --- a/run/seq-progress +++ b/run/seq-progress @@ -3,8 +3,7 @@ # Launch the sequencer progress popup GUI # -SCRIPT_DIR="$(cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd)" -NGPS_ROOT="${NGPS_ROOT:-$(cd -- "${SCRIPT_DIR}/.." && pwd)}" +NGPS_ROOT=/home/developer/Software CONFIG="${NGPS_ROOT}/Config/sequencerd.cfg" exec "${NGPS_ROOT}/bin/seq_progress_gui" --config "${CONFIG}" --group NONE --msgport 0 --poll-ms 10000 From fb5e57fd6541cbe36abcbbea464fb10df55417ef Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sun, 8 Feb 2026 01:19:10 -0800 Subject: [PATCH 59/74] Auto-detect NGPS_ROOT from script location - Find script's own directory using BASH_SOURCE - Set NGPS_ROOT to parent directory of script location - Works from any current working directory - Works for both /home/developer/Software and NGPS_tmp installs --- run/seq-progress | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/run/seq-progress b/run/seq-progress index 967e69ba..06667c6d 100755 --- a/run/seq-progress +++ b/run/seq-progress @@ -3,7 +3,8 @@ # Launch the sequencer progress popup GUI # -NGPS_ROOT=/home/developer/Software +SCRIPT_DIR="$(cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd)" +NGPS_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" CONFIG="${NGPS_ROOT}/Config/sequencerd.cfg" exec "${NGPS_ROOT}/bin/seq_progress_gui" --config "${CONFIG}" --group NONE --msgport 0 --poll-ms 10000 From 1e56fb81b2228b3500aaea220521f7d00634ddf2 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sun, 8 Feb 2026 01:20:32 -0800 Subject: [PATCH 60/74] Export NGPS_ROOT so seq_progress_gui can find acamd.cfg - Use 'export' so child process inherits NGPS_ROOT - Fixes 'ERROR: opening configuration file Config/acamd.cfg' - seq_progress_gui needs NGPS_ROOT to find acamd.cfg for ACAM port --- run/seq-progress | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/run/seq-progress b/run/seq-progress index 06667c6d..12f744c8 100755 --- a/run/seq-progress +++ b/run/seq-progress @@ -4,7 +4,7 @@ # SCRIPT_DIR="$(cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd)" -NGPS_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" +export NGPS_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" CONFIG="${NGPS_ROOT}/Config/sequencerd.cfg" exec "${NGPS_ROOT}/bin/seq_progress_gui" --config "${CONFIG}" --group NONE --msgport 0 --poll-ms 10000 From 0c598e3558707876f5a2a8e0f793079a73381c8a Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sun, 8 Feb 2026 23:23:24 -0800 Subject: [PATCH 61/74] Add contextual continue button and red instruction in seq-progress GUI When waiting for user continue after acquisition/fine-tune failure, the button now shows "OFFSET & EXPOSE" or "EXPOSE" depending on whether the target has a database offset. A blinking red instruction message tells the user exactly what clicking the button will do. The sequencer already applies target offset before exposure in both the guiding-failure and fine-tune-failure paths; the wait messages are updated to reflect this ("apply offset and expose"). Co-Authored-By: Claude Opus 4.6 --- sequencerd/sequence.cpp | 73 +++++++++++++++++++++++--------------- utils/seq_progress_gui.cpp | 46 +++++++++++++++++++++++- 2 files changed, 90 insertions(+), 29 deletions(-) diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index df0fd536..342964a6 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -870,49 +870,66 @@ namespace Sequencer { if ( acqerr != NO_ERROR ) { std::string reason = ( acqerr == TIMEOUT ? "timeout" : "error" ); this->async.enqueue_and_log( function, "WARNING: failed to reach guiding state ("+reason+"); falling back to manual continue" ); - if ( !wait_for_user( "waiting for USER to send \"continue\" signal to expose (guiding failed)" ) ) { + if ( !wait_for_user( "waiting for USER to send \"continue\" signal to apply offset and expose (guiding failed)" ) ) { this->async.enqueue_and_log( function, "NOTICE: sequence cancelled" ); return; } + // Apply offset if target has one, even though guiding failed + if ( this->target.offset_ra != 0.0 || this->target.offset_dec != 0.0 ) { + this->async.enqueue_and_log( function, "NOTICE: applying target offset" ); + this->offset_active.store(true); + this->publish_progress(); + error |= this->target_offset(); + this->offset_active.store(false); + if ( error != NO_ERROR ) { + this->thread_error_manager.set( THR_ACQUISITION ); + this->publish_progress(); + return; + } + if ( this->acq_offset_settle > 0 ) { + this->async.enqueue_and_log( function, "NOTICE: waiting for offset settle time" ); + std::this_thread::sleep_for( std::chrono::duration( this->acq_offset_settle ) ); + } + this->publish_progress(); + } } else { bool fine_tune_ok = ( run_fine_tune() == NO_ERROR ); if ( !fine_tune_ok ) { - this->async.enqueue_and_log( function, "WARNING: fine tune failed; waiting for USER continue to expose" ); - if ( !wait_for_user( "waiting for USER to send \"continue\" signal to expose (fine tune failed)" ) ) { + this->async.enqueue_and_log( function, "WARNING: fine tune failed; waiting for USER continue to apply offset and expose" ); + if ( !wait_for_user( "waiting for USER to send \"continue\" signal to apply offset and expose (fine tune failed)" ) ) { this->async.enqueue_and_log( function, "NOTICE: sequence cancelled" ); return; } } - if ( fine_tune_ok ) { - // acqmode 2: wait for user before offset - if ( this->acq_automatic_mode == 2 ) { - if ( !wait_for_user( "waiting for USER to send \"continue\" signal to apply offset and expose" ) ) { - this->async.enqueue_and_log( function, "NOTICE: sequence cancelled" ); - return; - } + // acqmode 2: wait for user before offset (only if fine-tune succeeded) + if ( fine_tune_ok && this->acq_automatic_mode == 2 ) { + if ( !wait_for_user( "waiting for USER to send \"continue\" signal to apply offset and expose" ) ) { + this->async.enqueue_and_log( function, "NOTICE: sequence cancelled" ); + return; } + } - // Apply offset for both acqmode 2 and 3 - if ( this->target.offset_ra != 0.0 || this->target.offset_dec != 0.0 ) { - std::string mode_str = (this->acq_automatic_mode == 3 ? "automatically " : ""); - this->async.enqueue_and_log( function, "NOTICE: applying target offset " + mode_str ); - this->offset_active.store(true); - this->publish_progress(); // Publish offset_active=true - error |= this->target_offset(); - this->offset_active.store(false); - if ( error != NO_ERROR ) { - this->thread_error_manager.set( THR_ACQUISITION ); - this->publish_progress(); // Publish with offset error state - return; - } - if ( this->acq_offset_settle > 0 ) { - this->async.enqueue_and_log( function, "NOTICE: waiting for offset settle time" ); - std::this_thread::sleep_for( std::chrono::duration( this->acq_offset_settle ) ); - } - this->publish_progress(); // Publish offset complete + // Apply offset for both acqmode 2 and 3, regardless of fine-tune success + // If target has offset defined, apply it before exposing + if ( this->target.offset_ra != 0.0 || this->target.offset_dec != 0.0 ) { + std::string mode_str = (this->acq_automatic_mode == 3 && fine_tune_ok) ? "automatically " : ""; + this->async.enqueue_and_log( function, "NOTICE: applying target offset " + mode_str ); + this->offset_active.store(true); + this->publish_progress(); // Publish offset_active=true + error |= this->target_offset(); + this->offset_active.store(false); + if ( error != NO_ERROR ) { + this->thread_error_manager.set( THR_ACQUISITION ); + this->publish_progress(); // Publish with offset error state + return; + } + if ( this->acq_offset_settle > 0 ) { + this->async.enqueue_and_log( function, "NOTICE: waiting for offset settle time" ); + std::this_thread::sleep_for( std::chrono::duration( this->acq_offset_settle ) ); } + this->publish_progress(); // Publish offset complete } } } diff --git a/utils/seq_progress_gui.cpp b/utils/seq_progress_gui.cpp index bf6d68d1..bced0f12 100644 --- a/utils/seq_progress_gui.cpp +++ b/utils/seq_progress_gui.cpp @@ -73,6 +73,8 @@ struct SequenceState { bool offset_applicable = false; bool waiting_for_user = false; bool waiting_for_tcsop = false; + bool user_wait_after_failure = false; + bool continue_will_expose = false; bool ontarget = false; bool guiding_on = false; bool guiding_failed = false; @@ -101,6 +103,8 @@ struct SequenceState { offset_applicable = false; waiting_for_user = false; waiting_for_tcsop = false; + user_wait_after_failure = false; + continue_will_expose = false; ontarget = false; guiding_on = false; guiding_failed = false; @@ -127,6 +131,8 @@ struct SequenceState { offset_applicable = false; waiting_for_user = false; waiting_for_tcsop = false; + user_wait_after_failure = false; + continue_will_expose = false; ontarget = false; guiding_on = false; guiding_failed = false; @@ -568,6 +574,7 @@ class SeqProgressGui { draw_title(); draw_bar(); draw_labels(); + draw_user_instruction(); draw_buttons(); draw_ontarget_indicator(); draw_offset_values(); @@ -691,9 +698,33 @@ class SeqProgressGui { XDrawString(display_, window_, gc_, tx, ty, label, std::strlen(label)); } + void draw_user_instruction() { + if (!state_.waiting_for_user || !state_.continue_will_expose) return; + if (!blink_on_) return; // blink the instruction for visibility + + bool has_offset = (state_.offset_ra != 0.0 || state_.offset_dec != 0.0); + char instruction[256]; + if (has_offset) { + snprintf(instruction, sizeof(instruction), + ">>> Click OFFSET & EXPOSE to apply offset (RA=%.2f\" DEC=%.2f\") then expose <<<", + state_.offset_ra, state_.offset_dec); + } else { + snprintf(instruction, sizeof(instruction), + ">>> Click EXPOSE to begin exposure (no target offset) <<<"); + } + + XSetForeground(display_, gc_, color_wait_); // red bold + XDrawString(display_, window_, gc_, 16, 118, instruction, std::strlen(instruction)); + } + void draw_buttons() { draw_button(ontarget_btn_, "ONTARGET", state_.waiting_for_tcsop); - draw_button(continue_btn_, "CONTINUE", state_.waiting_for_user); + const char *continue_label = "CONTINUE"; + if (state_.waiting_for_user && state_.continue_will_expose) { + bool has_offset = (state_.offset_ra != 0.0 || state_.offset_dec != 0.0); + continue_label = has_offset ? "OFFSET & EXPOSE" : "EXPOSE"; + } + draw_button(continue_btn_, continue_label, state_.waiting_for_user); } void draw_ontarget_indicator() { @@ -1221,9 +1252,22 @@ class SeqProgressGui { } if (msg.find("NOTICE: waiting for USER") != std::string::npos) { state_.waiting_for_user = true; + // Determine what "continue" will do based on the wait message + if (msg.find("start acquisition") != std::string::npos) { + state_.continue_will_expose = false; + } else { + state_.continue_will_expose = true; + } + // Detect if this is a failure-based user wait + if (msg.find("guiding failed") != std::string::npos || + msg.find("fine tune failed") != std::string::npos) { + state_.user_wait_after_failure = true; + } } if (msg.find("NOTICE: received continue") != std::string::npos) { state_.waiting_for_user = false; + state_.user_wait_after_failure = false; + state_.continue_will_expose = false; } if (msg.find("NOTICE: waiting for ACAM guiding") != std::string::npos) { state_.guiding_on = false; From b2a153759c5ebaa2edcb8d22d6382982e299d6a4 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Sun, 8 Feb 2026 23:37:06 -0800 Subject: [PATCH 62/74] Zero TCS offsets (native z) before applying target offset in acqmode 2/3 Sends 'native z' via tcsd before target_offset() in both the guiding-failure and fine-tune/post-fine-tune paths. This resets cumulative offsets from acquisition and fine-tuning so the observer sees only the intended target offset. Co-Authored-By: Claude Opus 4.6 --- sequencerd/sequence.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index 342964a6..f4580cf6 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -876,6 +876,9 @@ namespace Sequencer { } // Apply offset if target has one, even though guiding failed if ( this->target.offset_ra != 0.0 || this->target.offset_dec != 0.0 ) { + // Zero TCS offsets first so observer sees only the target offset + this->async.enqueue_and_log( function, "NOTICE: zeroing TCS offsets before applying target offset" ); + error |= this->tcsd.command( TCSD_NATIVE + " z" ); this->async.enqueue_and_log( function, "NOTICE: applying target offset" ); this->offset_active.store(true); this->publish_progress(); @@ -914,6 +917,9 @@ namespace Sequencer { // Apply offset for both acqmode 2 and 3, regardless of fine-tune success // If target has offset defined, apply it before exposing if ( this->target.offset_ra != 0.0 || this->target.offset_dec != 0.0 ) { + // Zero TCS offsets first so observer sees only the target offset + this->async.enqueue_and_log( function, "NOTICE: zeroing TCS offsets before applying target offset" ); + error |= this->tcsd.command( TCSD_NATIVE + " z" ); std::string mode_str = (this->acq_automatic_mode == 3 && fine_tune_ok) ? "automatically " : ""; this->async.enqueue_and_log( function, "NOTICE: applying target offset " + mode_str ); this->offset_active.store(true); From c8ce15c7201ef25d391c72715073779dd6dbbbf4 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Mon, 9 Feb 2026 11:35:04 -0800 Subject: [PATCH 63/74] Set FD_CLOEXEC on listening sockets to prevent fd inheritance by child processes When sequencerd forks child processes (e.g. ngps_acq), the listening socket file descriptors were inherited by the child. If the parent was killed while a child was still running, the child held the port open, preventing restart. Co-Authored-By: Claude Opus 4.6 --- utils/network.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/utils/network.cpp b/utils/network.cpp index 5b6a2402..1eee1af3 100644 --- a/utils/network.cpp +++ b/utils/network.cpp @@ -563,6 +563,10 @@ namespace Network { return(-1); } + // prevent child processes from inheriting the listening socket + // + fcntl(this->listenfd, F_SETFD, FD_CLOEXEC); + // allow re-binding to port while previous connection is in TIME_WAIT // int on=1; From 9fc708acb602514ff446ea697a672763bdbcda38 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Mon, 9 Feb 2026 11:49:59 -0800 Subject: [PATCH 64/74] Display active acqmode in seq-progress GUI Publish acq_automatic_mode in sequencer ZeroMQ progress messages and show it as a right-aligned label in the GUI title bar (MANUAL/SEMI-AUTO/AUTO). Also poll acqmode via TCP fallback for robustness. Co-Authored-By: Claude Opus 4.6 --- sequencerd/sequence.cpp | 3 +++ utils/seq_progress_gui.cpp | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index f4580cf6..c72678a6 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -216,6 +216,9 @@ namespace Sequencer { // Number of exposures for this target jmessage_out["nexp"] = this->target.nexp; + // Current acquisition mode + jmessage_out["acqmode"] = this->acq_automatic_mode; + try { this->publisher->publish( jmessage_out, "seq_progress" ); } diff --git a/utils/seq_progress_gui.cpp b/utils/seq_progress_gui.cpp index bced0f12..9845bd93 100644 --- a/utils/seq_progress_gui.cpp +++ b/utils/seq_progress_gui.cpp @@ -92,6 +92,7 @@ struct SequenceState { double offset_ra = 0.0; double offset_dec = 0.0; int nexp = 1; + int acqmode = 1; int current_frame = 0; int max_frame_seen = 0; @@ -572,6 +573,7 @@ class SeqProgressGui { XFillRectangle(display_, window_, gc_, 0, 0, kWinW, kWinH); draw_title(); + draw_acqmode_indicator(); draw_bar(); draw_labels(); draw_user_instruction(); @@ -590,6 +592,19 @@ class SeqProgressGui { XDrawString(display_, window_, gc_, 16, 24, title, std::strlen(title)); } + void draw_acqmode_indicator() { + const char *label; + switch (state_.acqmode) { + case 2: label = "ACQMODE: 2 SEMI-AUTO"; break; + case 3: label = "ACQMODE: 3 AUTO"; break; + default: label = "ACQMODE: 1 MANUAL"; break; + } + int len = std::strlen(label); + int text_width = XTextWidth(XQueryFont(display_, XGContextFromGC(gc_)), label, len); + XSetForeground(display_, gc_, color_text_); + XDrawString(display_, window_, gc_, kWinW - text_width - 16, 24, label, len); + } + void draw_bar() { for (int i = 0; i < kPhaseCount; ++i) { unsigned long fill = color_pending_; @@ -922,6 +937,9 @@ class SeqProgressGui { if (jmessage.contains("offset_dec") && jmessage["offset_dec"].is_number()) { state_.offset_dec = jmessage["offset_dec"].get(); } + if (jmessage.contains("acqmode") && jmessage["acqmode"].is_number()) { + state_.acqmode = jmessage["acqmode"].get(); + } if (jmessage.contains("nexp") && jmessage["nexp"].is_number()) { int new_nexp = jmessage["nexp"].get(); if (new_nexp != state_.nexp) { @@ -1059,6 +1077,15 @@ class SeqProgressGui { cmd_iface_.reconnect(); return; } + + // Poll acqmode — the no-arg response includes "current mode = N" + reply.clear(); + if (cmd_iface_.send_command("acqmode", reply, 200) == 0 && !reply.empty()) { + auto pos = reply.find("current mode = "); + if (pos != std::string::npos) { + try { state_.acqmode = std::stoi(reply.substr(pos + 15)); } catch (...) {} + } + } } void poll_acam() { From c97d60814e74a20e5e607580905d5533232d3ae0 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Mon, 9 Feb 2026 14:17:54 -0800 Subject: [PATCH 65/74] Publish acqmode updates for seq-progress and fix fine-tune log path comment --- Config/sequencerd.cfg.in | 2 +- sequencerd/sequencer_server.cpp | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Config/sequencerd.cfg.in b/Config/sequencerd.cfg.in index 9089771e..0507565c 100644 --- a/Config/sequencerd.cfg.in +++ b/Config/sequencerd.cfg.in @@ -162,7 +162,7 @@ ACQUIRE_MIN_REPEAT=2 # minimum number of sequential successful a ACQUIRE_TCS_MAX_OFFSET=60 # the maximum allowable offset sent to the TCS, in arcsec ACQ_AUTOMATIC_MODE=1 # 1=legacy, 2=semi-auto, 3=auto ACQ_FINE_TUNE_CMD=ngps_acq # command to run after guiding for final fine tune -ACQ_FINE_TUNE_LOG=1 # log fine tune output to /tmp/ngps_acq.log (0/1) +ACQ_FINE_TUNE_LOG=1 # log fine tune output to /data//logs/ngps_acq_.log (0/1) ACQ_OFFSET_SETTLE=3 # seconds to wait after automatic offset # Calibration Settings diff --git a/sequencerd/sequencer_server.cpp b/sequencerd/sequencer_server.cpp index da9b0251..23ca221e 100644 --- a/sequencerd/sequencer_server.cpp +++ b/sequencerd/sequencer_server.cpp @@ -1534,6 +1534,7 @@ namespace Sequencer { this->sequence.acq_automatic_mode = mode; message.str(""); message << "NOTICE: acqmode set to " << mode; this->sequence.async.enqueue_and_log( function, message.str() ); + this->sequence.publish_progress(); // push updated acqmode to seq-progress GUI retstring = std::to_string( mode ); ret = NO_ERROR; } From 6caa85ac7550a2948d966d1e6543ee355b8ab262 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Tue, 10 Feb 2026 13:00:29 -0800 Subject: [PATCH 66/74] Make seq-progress user button labels action-specific --- utils/seq_progress_gui.cpp | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/utils/seq_progress_gui.cpp b/utils/seq_progress_gui.cpp index 9845bd93..09577016 100644 --- a/utils/seq_progress_gui.cpp +++ b/utils/seq_progress_gui.cpp @@ -75,6 +75,7 @@ struct SequenceState { bool waiting_for_tcsop = false; bool user_wait_after_failure = false; bool continue_will_expose = false; + bool continue_starts_acquisition = false; bool ontarget = false; bool guiding_on = false; bool guiding_failed = false; @@ -106,6 +107,7 @@ struct SequenceState { waiting_for_tcsop = false; user_wait_after_failure = false; continue_will_expose = false; + continue_starts_acquisition = false; ontarget = false; guiding_on = false; guiding_failed = false; @@ -134,6 +136,7 @@ struct SequenceState { waiting_for_tcsop = false; user_wait_after_failure = false; continue_will_expose = false; + continue_starts_acquisition = false; ontarget = false; guiding_on = false; guiding_failed = false; @@ -663,7 +666,7 @@ class SeqProgressGui { if (state_.waiting_for_tcsop) { status = "WAITING FOR TCS OPERATOR (ONTARGET)"; } else if (state_.waiting_for_user) { - status = "WAITING FOR USER CONTINUE"; + status = "WAITING FOR USER ACTION"; } else if (state_.phase_active[PHASE_SLEW]) { status = "SLEWING"; } else if (state_.phase_active[PHASE_SOLVE]) { @@ -734,10 +737,14 @@ class SeqProgressGui { void draw_buttons() { draw_button(ontarget_btn_, "ONTARGET", state_.waiting_for_tcsop); - const char *continue_label = "CONTINUE"; - if (state_.waiting_for_user && state_.continue_will_expose) { - bool has_offset = (state_.offset_ra != 0.0 || state_.offset_dec != 0.0); - continue_label = has_offset ? "OFFSET & EXPOSE" : "EXPOSE"; + const char *continue_label = "EXPOSE"; + if (state_.waiting_for_user) { + if (state_.continue_starts_acquisition) { + continue_label = "START ACQUISITION"; + } else { + bool has_offset = (state_.offset_ra != 0.0 || state_.offset_dec != 0.0); + continue_label = has_offset ? "OFFSET & EXPOSE" : "EXPOSE"; + } } draw_button(continue_btn_, continue_label, state_.waiting_for_user); } @@ -1280,11 +1287,8 @@ class SeqProgressGui { if (msg.find("NOTICE: waiting for USER") != std::string::npos) { state_.waiting_for_user = true; // Determine what "continue" will do based on the wait message - if (msg.find("start acquisition") != std::string::npos) { - state_.continue_will_expose = false; - } else { - state_.continue_will_expose = true; - } + state_.continue_starts_acquisition = (msg.find("start acquisition") != std::string::npos); + state_.continue_will_expose = !state_.continue_starts_acquisition; // Detect if this is a failure-based user wait if (msg.find("guiding failed") != std::string::npos || msg.find("fine tune failed") != std::string::npos) { @@ -1295,6 +1299,7 @@ class SeqProgressGui { state_.waiting_for_user = false; state_.user_wait_after_failure = false; state_.continue_will_expose = false; + state_.continue_starts_acquisition = false; } if (msg.find("NOTICE: waiting for ACAM guiding") != std::string::npos) { state_.guiding_on = false; From 456dcafc740e7d05d9b794e853271f315002a402 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Tue, 10 Feb 2026 19:04:46 -0800 Subject: [PATCH 67/74] Fix USER gate labeling with polled ZMQ action state --- sequencerd/sequence.cpp | 33 ++++++++++++--- sequencerd/sequence.h | 12 ++++++ utils/seq_progress_gui.cpp | 85 +++++++++++++++++++++++++++----------- 3 files changed, 100 insertions(+), 30 deletions(-) diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index c72678a6..892207ed 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -60,6 +60,7 @@ namespace Sequencer { this->publish_seqstate(); this->publish_waitstate(); this->publish_daemonstate(); + this->publish_progress(); } /***** Sequencer::Sequence::publish_snapshot *******************************/ @@ -219,6 +220,16 @@ namespace Sequencer { // Current acquisition mode jmessage_out["acqmode"] = this->acq_automatic_mode; + // Explicit USER gate action for GUI button labeling + std::string user_gate_action = "NONE"; + switch ( this->user_gate_action.load() ) { + case USER_GATE_ACQUIRE: user_gate_action = "ACQUIRE"; break; + case USER_GATE_EXPOSE: user_gate_action = "EXPOSE"; break; + case USER_GATE_OFFSET_EXPOSE:user_gate_action = "OFFSET_EXPOSE"; break; + default: user_gate_action = "NONE"; break; + } + jmessage_out["user_gate_action"] = user_gate_action; + try { this->publisher->publish( jmessage_out, "seq_progress" ); } @@ -673,9 +684,11 @@ namespace Sequencer { this->async.enqueue_and_log( function, mode_msg.str() ); } - auto wait_for_user = [&](const std::string ¬ice) -> bool { + auto wait_for_user = [&](const std::string ¬ice, UserGateAction action) -> bool { this->is_usercontinue.store(false); ScopedState wait_state( wait_state_manager, Sequencer::SEQ_WAIT_USER ); + this->user_gate_action.store( action ); + this->publish_progress(); this->async.enqueue_and_log( function, "NOTICE: "+notice ); while ( !this->cancel_flag.load() && !this->is_usercontinue.load() ) { std::unique_lock lock(cv_mutex); @@ -684,6 +697,8 @@ namespace Sequencer { this->async.enqueue_and_log( function, "NOTICE: received " +(this->cancel_flag.load() ? std::string("cancel") : std::string("continue")) +" signal!" ); + this->user_gate_action.store( USER_GATE_NONE ); + this->publish_progress(); if ( this->cancel_flag.load() ) return false; this->is_usercontinue.store(false); return true; @@ -853,14 +868,14 @@ namespace Sequencer { }; if ( this->acq_automatic_mode == 1 ) { - if ( !wait_for_user( "waiting for USER to send \"continue\" signal" ) ) { + if ( !wait_for_user( "waiting for USER to send \"continue\" signal", USER_GATE_EXPOSE ) ) { this->async.enqueue_and_log( function, "NOTICE: sequence cancelled" ); return; } } else { if ( this->acq_automatic_mode == 2 ) { - if ( !wait_for_user( "waiting for USER to send \"continue\" signal to start acquisition" ) ) { + if ( !wait_for_user( "waiting for USER to send \"continue\" signal to start acquisition", USER_GATE_ACQUIRE ) ) { this->async.enqueue_and_log( function, "NOTICE: sequence cancelled" ); return; } @@ -873,7 +888,9 @@ namespace Sequencer { if ( acqerr != NO_ERROR ) { std::string reason = ( acqerr == TIMEOUT ? "timeout" : "error" ); this->async.enqueue_and_log( function, "WARNING: failed to reach guiding state ("+reason+"); falling back to manual continue" ); - if ( !wait_for_user( "waiting for USER to send \"continue\" signal to apply offset and expose (guiding failed)" ) ) { + UserGateAction gate_action = ( this->target.offset_ra != 0.0 || this->target.offset_dec != 0.0 ) + ? USER_GATE_OFFSET_EXPOSE : USER_GATE_EXPOSE; + if ( !wait_for_user( "waiting for USER to send \"continue\" signal to apply offset and expose (guiding failed)", gate_action ) ) { this->async.enqueue_and_log( function, "NOTICE: sequence cancelled" ); return; } @@ -903,7 +920,9 @@ namespace Sequencer { bool fine_tune_ok = ( run_fine_tune() == NO_ERROR ); if ( !fine_tune_ok ) { this->async.enqueue_and_log( function, "WARNING: fine tune failed; waiting for USER continue to apply offset and expose" ); - if ( !wait_for_user( "waiting for USER to send \"continue\" signal to apply offset and expose (fine tune failed)" ) ) { + UserGateAction gate_action = ( this->target.offset_ra != 0.0 || this->target.offset_dec != 0.0 ) + ? USER_GATE_OFFSET_EXPOSE : USER_GATE_EXPOSE; + if ( !wait_for_user( "waiting for USER to send \"continue\" signal to apply offset and expose (fine tune failed)", gate_action ) ) { this->async.enqueue_and_log( function, "NOTICE: sequence cancelled" ); return; } @@ -911,7 +930,9 @@ namespace Sequencer { // acqmode 2: wait for user before offset (only if fine-tune succeeded) if ( fine_tune_ok && this->acq_automatic_mode == 2 ) { - if ( !wait_for_user( "waiting for USER to send \"continue\" signal to apply offset and expose" ) ) { + UserGateAction gate_action = ( this->target.offset_ra != 0.0 || this->target.offset_dec != 0.0 ) + ? USER_GATE_OFFSET_EXPOSE : USER_GATE_EXPOSE; + if ( !wait_for_user( "waiting for USER to send \"continue\" signal to apply offset and expose", gate_action ) ) { this->async.enqueue_and_log( function, "NOTICE: sequence cancelled" ); return; } diff --git a/sequencerd/sequence.h b/sequencerd/sequence.h index 92381314..6b2ebf1c 100644 --- a/sequencerd/sequence.h +++ b/sequencerd/sequence.h @@ -182,6 +182,17 @@ namespace Sequencer { {SEQ_WAIT_USER, "USER"} }; + /** + * @enum UserGateAction + * @brief explicit action represented by a USER wait gate + */ + enum UserGateAction : int { + USER_GATE_NONE = 0, + USER_GATE_ACQUIRE, + USER_GATE_EXPOSE, + USER_GATE_OFFSET_EXPOSE + }; + /** * @enum ThreadStatusBits * @brief assigns each thread a bit in a threadstate word @@ -291,6 +302,7 @@ namespace Sequencer { std::atomic is_usercontinue{false}; ///< remotely set by the user to continue std::atomic fine_tune_pid{0}; ///< fine tune process pid (process group leader) std::atomic offset_active{false}; ///< tracks offset operation in progress + std::atomic user_gate_action{USER_GATE_NONE}; ///< explicit USER gate action for GUI labeling /** @brief safely runs function in a detached thread using lambda to catch exceptions */ diff --git a/utils/seq_progress_gui.cpp b/utils/seq_progress_gui.cpp index 09577016..2d0e3840 100644 --- a/utils/seq_progress_gui.cpp +++ b/utils/seq_progress_gui.cpp @@ -74,8 +74,8 @@ struct SequenceState { bool waiting_for_user = false; bool waiting_for_tcsop = false; bool user_wait_after_failure = false; - bool continue_will_expose = false; - bool continue_starts_acquisition = false; + bool user_gate_action_polled = false; + std::string user_gate_action = "NONE"; // NONE|ACQUIRE|EXPOSE|OFFSET_EXPOSE bool ontarget = false; bool guiding_on = false; bool guiding_failed = false; @@ -106,8 +106,8 @@ struct SequenceState { waiting_for_user = false; waiting_for_tcsop = false; user_wait_after_failure = false; - continue_will_expose = false; - continue_starts_acquisition = false; + user_gate_action_polled = false; + user_gate_action = "NONE"; ontarget = false; guiding_on = false; guiding_failed = false; @@ -135,8 +135,8 @@ struct SequenceState { waiting_for_user = false; waiting_for_tcsop = false; user_wait_after_failure = false; - continue_will_expose = false; - continue_starts_acquisition = false; + user_gate_action_polled = false; + user_gate_action = "NONE"; ontarget = false; guiding_on = false; guiding_failed = false; @@ -717,18 +717,26 @@ class SeqProgressGui { } void draw_user_instruction() { - if (!state_.waiting_for_user || !state_.continue_will_expose) return; + if (!state_.waiting_for_user) return; if (!blink_on_) return; // blink the instruction for visibility - bool has_offset = (state_.offset_ra != 0.0 || state_.offset_dec != 0.0); char instruction[256]; - if (has_offset) { + if (!state_.user_gate_action_polled || state_.user_gate_action == "NONE") { + snprintf(instruction, sizeof(instruction), + ">>> Click CONTINUE <<<"); + } else if (state_.user_gate_action == "ACQUIRE") { + snprintf(instruction, sizeof(instruction), + ">>> Click ACQUIRE to start acquisition <<<"); + } else if (state_.user_gate_action == "OFFSET_EXPOSE") { snprintf(instruction, sizeof(instruction), ">>> Click OFFSET & EXPOSE to apply offset (RA=%.2f\" DEC=%.2f\") then expose <<<", state_.offset_ra, state_.offset_dec); - } else { + } else if (state_.user_gate_action == "EXPOSE") { snprintf(instruction, sizeof(instruction), ">>> Click EXPOSE to begin exposure (no target offset) <<<"); + } else { + snprintf(instruction, sizeof(instruction), + ">>> Waiting for sequencer USER action details... <<<"); } XSetForeground(display_, gc_, color_wait_); // red bold @@ -737,13 +745,14 @@ class SeqProgressGui { void draw_buttons() { draw_button(ontarget_btn_, "ONTARGET", state_.waiting_for_tcsop); - const char *continue_label = "EXPOSE"; - if (state_.waiting_for_user) { - if (state_.continue_starts_acquisition) { - continue_label = "START ACQUISITION"; - } else { - bool has_offset = (state_.offset_ra != 0.0 || state_.offset_dec != 0.0); - continue_label = has_offset ? "OFFSET & EXPOSE" : "EXPOSE"; + const char *continue_label = "CONTINUE"; + if (state_.waiting_for_user && state_.user_gate_action_polled) { + if (state_.user_gate_action == "ACQUIRE") { + continue_label = "ACQUIRE"; + } else if (state_.user_gate_action == "OFFSET_EXPOSE") { + continue_label = "OFFSET & EXPOSE"; + } else if (state_.user_gate_action == "EXPOSE") { + continue_label = "EXPOSE"; } } draw_button(continue_btn_, continue_label, state_.waiting_for_user); @@ -947,6 +956,21 @@ class SeqProgressGui { if (jmessage.contains("acqmode") && jmessage["acqmode"].is_number()) { state_.acqmode = jmessage["acqmode"].get(); } + if (jmessage.contains("user_gate_action") && jmessage["user_gate_action"].is_string()) { + std::string gate = to_upper_copy(jmessage["user_gate_action"].get()); + if (gate != "ACQUIRE" && gate != "EXPOSE" && gate != "OFFSET_EXPOSE" && gate != "NONE") { + gate = "NONE"; + } + if (state_.waiting_for_user) { + if (gate != "NONE") { + state_.user_gate_action = gate; + state_.user_gate_action_polled = true; + } + } else { + state_.user_gate_action = gate; + state_.user_gate_action_polled = false; + } + } if (jmessage.contains("nexp") && jmessage["nexp"].is_number()) { int new_nexp = jmessage["nexp"].get(); if (new_nexp != state_.nexp) { @@ -1030,6 +1054,12 @@ class SeqProgressGui { const bool allow_tcp_poll = !have_zmq || (zmq_quiet && stale_seq); if (options_.poll_ms > 0) { + // During USER wait, keep polling snapshots until gate action arrives. + if (have_zmq && zmq_pub_ && state_.waiting_for_user && !state_.user_gate_action_polled && + std::chrono::duration_cast(now - last_snapshot_request_).count() >= 1000) { + request_snapshot(); + updated = true; + } if (have_zmq && zmq_pub_) { if (stale_seq && std::chrono::duration_cast(now - last_snapshot_request_).count() >= stale_ms) { @@ -1137,6 +1167,7 @@ class SeqProgressGui { } void handle_waitstate(const std::string &waitstate) { + bool was_waiting_for_user = state_.waiting_for_user; state_.waitstate = waitstate; last_waitstate_update_ = std::chrono::steady_clock::now(); auto tokens = split_ws(waitstate); @@ -1149,6 +1180,17 @@ class SeqProgressGui { state_.waiting_for_tcsop = has_tcsop; state_.waiting_for_user = has_user; + if (has_user && !was_waiting_for_user) { + // New USER gate: default to CONTINUE until explicit gate action is polled. + state_.user_gate_action = "NONE"; + state_.user_gate_action_polled = false; + request_snapshot(); + } + if (!has_user) { + // USER gate action applies only while WAITSTATE includes USER. + state_.user_gate_action = "NONE"; + state_.user_gate_action_polled = false; + } if (!has_tcsop && (has_acquire || has_guide || has_expose || has_readout || has_user)) { state_.ontarget = true; @@ -1285,10 +1327,8 @@ class SeqProgressGui { state_.last_ontarget = std::chrono::steady_clock::now(); } if (msg.find("NOTICE: waiting for USER") != std::string::npos) { - state_.waiting_for_user = true; - // Determine what "continue" will do based on the wait message - state_.continue_starts_acquisition = (msg.find("start acquisition") != std::string::npos); - state_.continue_will_expose = !state_.continue_starts_acquisition; + // USER gate intent is driven by seq_waitstate + seq_progress.user_gate_action. + // Ignore async NOTICE text so UDP/non-ZMQ paths cannot override ZMQ truth. // Detect if this is a failure-based user wait if (msg.find("guiding failed") != std::string::npos || msg.find("fine tune failed") != std::string::npos) { @@ -1296,10 +1336,7 @@ class SeqProgressGui { } } if (msg.find("NOTICE: received continue") != std::string::npos) { - state_.waiting_for_user = false; state_.user_wait_after_failure = false; - state_.continue_will_expose = false; - state_.continue_starts_acquisition = false; } if (msg.find("NOTICE: waiting for ACAM guiding") != std::string::npos) { state_.guiding_on = false; From f97c905d21cb99e6b6bd6d131f057c0439743a1c Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Tue, 10 Feb 2026 19:42:16 -0800 Subject: [PATCH 68/74] Move acquisition fine-tune to slicecamd autoacq --- Config/slicecamd.cfg.in | 3 + common/ngps_acq_embed.h | 39 + common/slicecamd_commands.h | 2 + sequencerd/sequence.cpp | 314 ++-- sequencerd/sequence.h | 18 +- sequencerd/sequencerd.cpp | 2 +- slicecamd/CMakeLists.txt | 7 + slicecamd/ngps_acq.c | 2455 ++++++++++++++++++++++++++++++ slicecamd/slicecam_interface.cpp | 556 ++++++- slicecamd/slicecam_interface.h | 27 +- slicecamd/slicecam_server.cpp | 4 + 11 files changed, 3275 insertions(+), 152 deletions(-) create mode 100644 common/ngps_acq_embed.h create mode 100644 slicecamd/ngps_acq.c diff --git a/Config/slicecamd.cfg.in b/Config/slicecamd.cfg.in index 53873240..aa28e937 100644 --- a/Config/slicecamd.cfg.in +++ b/Config/slicecamd.cfg.in @@ -22,6 +22,9 @@ SUB_ENDPOINT="tcp://127.0.0.1:@MESSAGE_BROKER_PUB_PORT@" # TCSD_PORT=@TCSD_BLK_PORT@ ACAMD_PORT=@ACAMD_BLK_PORT@ +# AUTOACQ_ARGS defines defaults for slicecamd in-process ngps_acq. +# This line intentionally includes all supported ngps_acq options. +AUTOACQ_ARGS="--input /tmp/slicecam.fits --frame-mode stream --framegrab-out /tmp/ngps_acq.fits --goal-x 151.25 --goal-y 115.25 --pixel-origin 1 --max-dist 40 --snr 10 --min-adj 4 --filt-sigma 1.2 --centroid-hw 6 --centroid-sigma 2.0 --extname L --extnum 1 --bg-x1 80 --bg-x2 200 --bg-y1 30 --bg-y2 210 --search-x1 80 --search-x2 200 --search-y1 30 --search-y2 210 --loop 1 --cadence-sec 2 --max-samples 10 --min-samples 3 --prec-arcsec 0.15 --goal-arcsec 0.15 --max-cycles 20 --gain 1.0 --adaptive 1 --adaptive-faint 500 --adaptive-faint-goal 500 --adaptive-bright 40000 --adaptive-bright-goal 10000 --reject-identical 1 --reject-after-move 3 --settle-sec 0.0 --max-move-arcsec 10 --continue-on-fail 0 --tcs-set-units 0 --use-putonslit 1 --dra-use-cosdec 1 --tcs-sign +1 --wait-guiding 1 --guiding-poll-sec 1.0 --guiding-timeout-sec 120 --debug 1 --debug-out ./ngps_acq_debug.ppm --dry-run 0 --verbose 1" # ANDOR=( [emulate] ) # For each slice camera specify: diff --git a/common/ngps_acq_embed.h b/common/ngps_acq_embed.h new file mode 100644 index 00000000..530a1581 --- /dev/null +++ b/common/ngps_acq_embed.h @@ -0,0 +1,39 @@ +/** + * @file ngps_acq_embed.h + * @brief in-process API for NGPS auto-acquire logic + */ + +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct ngps_acq_hooks { + void *user; + + int (*tcs_set_native_units)( void *user, int dry_run, int verbose ); + int (*tcs_move_arcsec)( void *user, double dra_arcsec, double ddec_arcsec, + int dry_run, int verbose ); + int (*scam_putonslit_deg)( void *user, + double slit_ra_deg, double slit_dec_deg, + double cross_ra_deg, double cross_dec_deg, + int dry_run, int verbose ); + int (*acam_query_state)( void *user, char *state, size_t state_sz, int verbose ); + int (*scam_framegrab_one)( void *user, const char *outpath, int verbose ); + int (*scam_set_exptime)( void *user, double exptime_sec, int dry_run, int verbose ); + int (*scam_set_avgframes)( void *user, int avgframes, int dry_run, int verbose ); + int (*is_stop_requested)( void *user ); + void (*log_message)( void *user, const char *line ); +} ngps_acq_hooks_t; + +void ngps_acq_set_hooks( const ngps_acq_hooks_t *hooks ); +void ngps_acq_clear_hooks( void ); +void ngps_acq_request_stop( int stop_requested ); +int ngps_acq_run_from_argv( int argc, char **argv ); + +#ifdef __cplusplus +} +#endif diff --git a/common/slicecamd_commands.h b/common/slicecamd_commands.h index 1ddad325..08b17dec 100644 --- a/common/slicecamd_commands.h +++ b/common/slicecamd_commands.h @@ -10,6 +10,7 @@ #pragma once const std::string SLICECAMD_AVGFRAMES= "avgframes"; ///< set/get camera binning +const std::string SLICECAMD_AUTOACQ = "autoacq"; ///< run/monitor in-process auto-acquire logic const std::string SLICECAMD_BIN = "bin"; ///< set/get camera binning const std::string SLICECAMD_CLOSE = "close"; ///< *** close connection to all devices const std::string SLICECAMD_CONFIG = "config"; ///< reload configuration, apply what can be applied @@ -69,6 +70,7 @@ const std::vector SLICECAMD_SYNTAX = { SLICECAMD_SPEED+" [ ? | ]", SLICECAMD_TEMP+" [ ? | ]", " OTHER:", + SLICECAMD_AUTOACQ+" [ ? | start [--log-file ] [] | stop | status ]", SLICECAMD_PUTONSLIT+" [ ? | ]", SLICECAMD_SAVEFRAMES+" [ ? | ]", SLICECAMD_SHUTDOWN+" [ ? ]", diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index 892207ed..b6ff9b9a 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -13,10 +13,7 @@ #include "sequence.h" -#include -#include -#include -#include +#include namespace Sequencer { @@ -45,6 +42,57 @@ namespace Sequencer { /***** Sequencer::Sequence::handletopic_snapshot ***************************/ + /***** Sequencer::Sequence::handletopic_slicecam_autoacq ********************/ + /** + * @brief handles slicecamd autoacq status updates + * @param[in] jmessage_in subscribed-received JSON message + * + */ + void Sequence::handletopic_slicecam_autoacq( const nlohmann::json &jmessage_in ) { + if ( !jmessage_in.contains("state") ) return; + if ( jmessage_in.contains("source") && + jmessage_in.at("source").is_string() && + jmessage_in.at("source").get() != "slicecamd" ) return; + + std::string state = jmessage_in.value( "state", "" ); + std::string message = jmessage_in.value( "message", "" ); + uint64_t run_id = jmessage_in.value( "run_id", static_cast(0) ); + + bool should_notify = false; + bool should_publish = false; + { + std::lock_guard lock( this->fine_tune_mtx ); + + // Ignore stale messages when waiting on a specific run_id. + if ( this->fine_tune_run_id != 0 && run_id != 0 && run_id != this->fine_tune_run_id ) return; + + if ( state == "running" ) { + this->fine_tune_active.store( true, std::memory_order_release ); + this->fine_tune_done = false; + this->fine_tune_success = false; + this->fine_tune_message = message; + should_publish = true; + } + else if ( state == "success" || state == "failed" || state == "aborted" ) { + this->fine_tune_active.store( false, std::memory_order_release ); + this->fine_tune_done = true; + this->fine_tune_success = ( state == "success" ); + this->fine_tune_message = message; + should_notify = true; + should_publish = true; + } + else if ( state == "idle" ) { + this->fine_tune_active.store( false, std::memory_order_release ); + should_publish = true; + } + } + + if ( should_publish ) this->publish_progress(); + if ( should_notify ) this->fine_tune_cv.notify_all(); + } + /***** Sequencer::Sequence::handletopic_slicecam_autoacq ********************/ + + /***** Sequencer::Sequence::publish_snapshot *******************************/ /** * @brief publishes snapshot of my telemetry @@ -193,8 +241,8 @@ namespace Sequencer { nlohmann::json jmessage_out; jmessage_out["source"] = Sequencer::DAEMON_NAME; - // Track fine-tune status via fine_tune_pid - jmessage_out["fine_tune_active"] = (this->fine_tune_pid.load() != 0); + // Track fine-tune status from slicecamd autoacq state + jmessage_out["fine_tune_active"] = this->fine_tune_active.load(std::memory_order_acquire); // Track offset status jmessage_out["offset_active"] = this->offset_active.load(); @@ -726,145 +774,144 @@ namespace Sequencer { auto run_fine_tune = [&]() -> long { if ( this->acq_fine_tune_cmd.empty() ) return NO_ERROR; - this->async.enqueue_and_log( function, "NOTICE: running fine tune command: "+this->acq_fine_tune_cmd ); - - // Construct log filename using same pattern as daemon logs: /data/{datedir}/logs/ngps_acq_{datedir}.log - std::string datedir = get_latest_datedir( "/data" ); - std::stringstream logfilename; - logfilename << "/data/" << datedir << "/logs/ngps_acq_" << datedir << ".log"; - std::string acq_logfile = logfilename.str(); - - // Build command with optional logging redirection std::string cmd_to_run = this->acq_fine_tune_cmd; + std::string acq_logfile; if ( this->acq_fine_tune_log ) { - cmd_to_run += " >> " + acq_logfile + " 2>&1"; + std::string datedir = get_latest_datedir( "/data" ); + if ( !datedir.empty() ) { + std::stringstream logfilename; + logfilename << "/data/" << datedir << "/logs/ngps_acq_" << datedir << ".log"; + acq_logfile = logfilename.str(); + } + else { + acq_logfile = "/tmp/ngps_acq.log"; + this->async.enqueue_and_log( function, "NOTICE: /data datedir not found, logging fine tune output to "+acq_logfile ); + } + } + + auto append_fine_tune_log = [&]( const std::string &line ) { + if ( !this->acq_fine_tune_log || acq_logfile.empty() ) return; + std::ofstream logfile( acq_logfile, std::ios::app ); + if ( logfile.is_open() ) { + logfile << "=== SEQUENCER: " << line << " ===" << std::endl; + } + }; + + if ( this->acq_fine_tune_log && !acq_logfile.empty() ) { this->async.enqueue_and_log( function, "NOTICE: logging fine tune output to "+acq_logfile ); } - // Temporarily restore default SIGCHLD handling so we can waitpid() on this child. - // The daemon has SIGCHLD=SIG_IGN which causes kernel to auto-reap children. - struct sigaction old_action, new_action; - memset(&new_action, 0, sizeof(new_action)); - new_action.sa_handler = SIG_DFL; - sigemptyset(&new_action.sa_mask); - sigaction(SIGCHLD, &new_action, &old_action); - - pid_t pid = fork(); - if ( pid == 0 ) { - // make a dedicated process group so we can signal the whole tree - setpgid( 0, 0 ); - execl( "/bin/sh", "sh", "-c", cmd_to_run.c_str(), (char*)nullptr ); - _exit(127); + this->async.enqueue_and_log( function, "NOTICE: starting slicecamd autoacq: "+cmd_to_run ); + + { + std::lock_guard lock( this->fine_tune_mtx ); + this->fine_tune_done = false; + this->fine_tune_success = false; + this->fine_tune_run_id = 0; + this->fine_tune_message.clear(); } - if ( pid < 0 ) { - logwrite( function, "ERROR starting fine tune command: "+this->acq_fine_tune_cmd ); - sigaction(SIGCHLD, &old_action, nullptr); // Restore old handler + + std::string start_reply; + std::string start_cmd = SLICECAMD_AUTOACQ + " start"; + if ( !acq_logfile.empty() ) start_cmd += " --log-file " + acq_logfile; + if ( !cmd_to_run.empty() ) start_cmd += " " + cmd_to_run; + this->fine_tune_active.store( true, std::memory_order_release ); + this->publish_progress(); + + if ( this->slicecamd.command( start_cmd, start_reply ) != NO_ERROR ) { + this->fine_tune_active.store( false, std::memory_order_release ); + this->publish_progress(); + this->async.enqueue_and_log( function, "ERROR failed to start slicecamd autoacq" ); + append_fine_tune_log( "failed to start slicecamd autoacq" ); return ERROR; } - // Ensure the child is its own process group (best effort). - setpgid( pid, pid ); - this->fine_tune_pid.store( pid ); - this->publish_progress(); // Publish fine_tune_active=true - int status = 0; + auto runpos = start_reply.find( "run_id=" ); + if ( runpos != std::string::npos ) { + try { + std::string runid_str = start_reply.substr( runpos + 7 ); + std::vector tokens; + Tokenize( runid_str, tokens, " " ); + if ( !tokens.empty() ) { + std::lock_guard lock( this->fine_tune_mtx ); + this->fine_tune_run_id = std::stoull( tokens.at(0) ); + } + } + catch ( const std::exception & ) { } + } + + bool sent_stop = false; + auto stop_deadline = std::chrono::steady_clock::time_point::min(); + auto next_status_poll = std::chrono::steady_clock::now(); while ( true ) { - pid_t result = waitpid( pid, &status, WNOHANG ); - if ( result == pid ) break; - if ( result < 0 ) { - std::stringstream errmsg; - errmsg << "ERROR waiting on fine tune command: waitpid returned " << result - << " errno=" << errno << " (" << strerror(errno) << ")"; - logwrite( function, errmsg.str() ); - if ( this->acq_fine_tune_log ) { - std::ofstream logfile(acq_logfile, std::ios::app); - if ( logfile.is_open() ) { - logfile << "=== SEQUENCER: " << errmsg.str() << " ===" << std::endl; - logfile.close(); + { + std::unique_lock lock( this->fine_tune_mtx ); + if ( this->fine_tune_cv.wait_for( lock, std::chrono::milliseconds(200), + [this](){ return this->fine_tune_done || this->cancel_flag.load(); } ) ) { + if ( this->fine_tune_done ) break; + } + } + + // Fallback in case status topic messages are delayed or dropped. + if ( std::chrono::steady_clock::now() >= next_status_poll ) { + next_status_poll = std::chrono::steady_clock::now() + std::chrono::seconds(1); + std::string status_reply; + if ( this->slicecamd.command( SLICECAMD_AUTOACQ + " status", status_reply ) == NO_ERROR ) { + auto pos = status_reply.find( "state=" ); + if ( pos != std::string::npos ) { + std::string statestr = status_reply.substr( pos + 6 ); + std::vector tokens; + Tokenize( statestr, tokens, " " ); + if ( !tokens.empty() ) { + if ( tokens.at(0) == "success" || tokens.at(0) == "failed" || tokens.at(0) == "aborted" ) { + std::lock_guard lock( this->fine_tune_mtx ); + this->fine_tune_done = true; + this->fine_tune_success = ( tokens.at(0) == "success" ); + this->fine_tune_message = "fine tune " + tokens.at(0); + break; + } + } } } - sigaction(SIGCHLD, &old_action, nullptr); // Restore old handler - return ERROR; } + if ( this->cancel_flag.load() ) { - this->async.enqueue_and_log( function, "NOTICE: abort requested; terminating fine tune" ); - // terminate the whole fine-tune process group - kill( -pid, SIGTERM ); - auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); - while ( std::chrono::steady_clock::now() < deadline ) { - result = waitpid( pid, &status, WNOHANG ); - if ( result == pid ) break; - std::this_thread::sleep_for( std::chrono::milliseconds(100) ); + if ( !sent_stop ) { + this->async.enqueue_and_log( function, "NOTICE: abort requested; stopping slicecamd autoacq" ); + this->slicecamd.command( SLICECAMD_AUTOACQ + " stop" ); + sent_stop = true; + stop_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); } - if ( result != pid ) { - kill( -pid, SIGKILL ); - waitpid( pid, &status, 0 ); + else + if ( stop_deadline != std::chrono::steady_clock::time_point::min() && + std::chrono::steady_clock::now() > stop_deadline ) { + break; } - this->fine_tune_pid.store( 0 ); - sigaction(SIGCHLD, &old_action, nullptr); // Restore old handler - return ERROR; } - std::this_thread::sleep_for( std::chrono::milliseconds(100) ); } - this->fine_tune_pid.store( 0 ); - - // Restore old SIGCHLD handler now that we've reaped the child - sigaction(SIGCHLD, &old_action, nullptr); - - // Log detailed exit status information - std::stringstream status_msg; - status_msg << "fine tune process exit status: raw=" << status; - - if ( WIFEXITED( status ) ) { - int exit_code = WEXITSTATUS( status ); - status_msg << " exited_normally=true exit_code=" << exit_code; - logwrite( function, status_msg.str() ); + bool success = false; + std::string fine_tune_message; + { + std::lock_guard lock( this->fine_tune_mtx ); + success = this->fine_tune_success; + fine_tune_message = this->fine_tune_message; + this->fine_tune_run_id = 0; + } - if ( this->acq_fine_tune_log ) { - std::ofstream logfile(acq_logfile, std::ios::app); - if ( logfile.is_open() ) { - logfile << "=== SEQUENCER: " << status_msg.str() << " ===" << std::endl; - logfile.close(); - } - } + this->fine_tune_active.store( false, std::memory_order_release ); + this->publish_progress(); - if ( exit_code == 0 ) { - this->async.enqueue_and_log( function, "NOTICE: fine tune complete" ); - this->publish_progress(); // Publish fine_tune_active=false (success) - return NO_ERROR; - } - else { - message.str(""); message << "ERROR: fine tune command exited with code " << exit_code; - this->async.enqueue_and_log( function, message.str() ); - this->publish_progress(); // Publish fine_tune_active=false (failure) - return ERROR; - } + if ( success ) { + this->async.enqueue_and_log( function, "NOTICE: fine tune complete" ); + append_fine_tune_log( "fine tune complete" ); + return NO_ERROR; } - else if ( WIFSIGNALED( status ) ) { - int signal = WTERMSIG( status ); - status_msg << " exited_normally=false terminated_by_signal=" << signal; - logwrite( function, status_msg.str() ); - - if ( this->acq_fine_tune_log ) { - std::ofstream logfile(acq_logfile, std::ios::app); - if ( logfile.is_open() ) { - logfile << "=== SEQUENCER: " << status_msg.str() << " ===" << std::endl; - logfile.close(); - } - } - message.str(""); message << "ERROR: fine tune command terminated by signal " << signal; - this->async.enqueue_and_log( function, message.str() ); - this->publish_progress(); // Publish fine_tune_active=false (terminated) - return ERROR; - } - else { - status_msg << " unknown_exit_condition"; - logwrite( function, status_msg.str() ); - logwrite( function, "ERROR fine tune command failed: "+this->acq_fine_tune_cmd ); - this->publish_progress(); // Publish fine_tune_active=false (unknown) - return ERROR; - } + if ( fine_tune_message.empty() ) fine_tune_message = "fine tune failed"; + this->async.enqueue_and_log( function, "ERROR: "+fine_tune_message ); + append_fine_tune_log( fine_tune_message ); + return ERROR; }; if ( this->acq_automatic_mode == 1 ) { @@ -2584,24 +2631,13 @@ namespace Sequencer { this->cancel_flag.store(true); this->cv.notify_all(); - // terminate fine tune process if running + // request slicecamd to stop autoacq if it is running // - pid_t ftpid = this->fine_tune_pid.load(); - if ( ftpid > 0 ) { - logwrite( function, "NOTICE: terminating fine tune process" ); - kill( -ftpid, SIGTERM ); - auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); - int status = 0; - while ( std::chrono::steady_clock::now() < deadline ) { - pid_t result = waitpid( ftpid, &status, WNOHANG ); - if ( result == ftpid ) break; - std::this_thread::sleep_for( std::chrono::milliseconds(100) ); - } - if ( waitpid( ftpid, &status, WNOHANG ) == 0 ) { - kill( -ftpid, SIGKILL ); - waitpid( ftpid, &status, 0 ); - } - this->fine_tune_pid.store( 0 ); + if ( this->fine_tune_active.load(std::memory_order_acquire) ) { + logwrite( function, "NOTICE: stopping slicecamd autoacq" ); + this->slicecamd.command( SLICECAMD_AUTOACQ + " stop" ); + this->fine_tune_active.store( false, std::memory_order_release ); + this->publish_progress(); } // drop into do-one to prevent auto increment to next target diff --git a/sequencerd/sequence.h b/sequencerd/sequence.h index 6b2ebf1c..a10681d6 100644 --- a/sequencerd/sequence.h +++ b/sequencerd/sequence.h @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -300,7 +301,7 @@ namespace Sequencer { std::atomic cancel_flag{false}; std::atomic is_ontarget{false}; ///< remotely set by the TCS operator to indicate that the target is ready std::atomic is_usercontinue{false}; ///< remotely set by the user to continue - std::atomic fine_tune_pid{0}; ///< fine tune process pid (process group leader) + std::atomic fine_tune_active{false}; ///< fine tune running state reported by slicecamd autoacq std::atomic offset_active{false}; ///< tracks offset operation in progress std::atomic user_gate_action{USER_GATE_NONE}; ///< explicit USER gate action for GUI labeling @@ -356,7 +357,9 @@ namespace Sequencer { topic_handlers = { { "_snapshot", std::function( - [this](const nlohmann::json &msg) { handletopic_snapshot(msg); } ) } + [this](const nlohmann::json &msg) { handletopic_snapshot(msg); } ) }, + { "slicecam_autoacq", std::function( + [this](const nlohmann::json &msg) { handletopic_slicecam_autoacq(msg); } ) } }; } @@ -391,8 +394,8 @@ namespace Sequencer { double acquisition_timeout; ///< timeout for target acquisition (in sec) set by configuration parameter ACAM_ACQUIRE_TIMEOUT int acquisition_max_retrys; ///< max number of acquisition loop attempts int acq_automatic_mode; ///< acquisition automation mode (1=legacy, 2=semi-auto, 3=auto) - std::string acq_fine_tune_cmd; ///< fine-tune command to run after guiding - bool acq_fine_tune_log; ///< log fine-tune output to /tmp/ngps_acq.log + std::string acq_fine_tune_cmd; ///< optional fine-tune args override passed to slicecamd autoacq + bool acq_fine_tune_log; ///< log fine-tune output to /data//logs/ngps_acq_.log double acq_offset_settle; ///< seconds to wait after automatic offset double tcs_offsetrate_ra; ///< TCS offset rate RA ("MRATE") in arcsec per second double tcs_offsetrate_dec; ///< TCS offset rate DEC ("MRATE") in arcsec per second @@ -407,6 +410,12 @@ namespace Sequencer { std::mutex wait_mtx; std::condition_variable cv; std::mutex cv_mutex; + std::mutex fine_tune_mtx; + std::condition_variable fine_tune_cv; + bool fine_tune_done{false}; + bool fine_tune_success{false}; + uint64_t fine_tune_run_id{0}; + std::string fine_tune_message; std::mutex monitor_mtx; std::map sequence_states; @@ -473,6 +482,7 @@ namespace Sequencer { void stop_subscriber_thread() { Common::PubSubHandler::stop_subscriber_thread(*this); } void handletopic_snapshot( const nlohmann::json &jmessage ); + void handletopic_slicecam_autoacq( const nlohmann::json &jmessage ); void publish_snapshot(); void publish_snapshot(std::string &retstring); void publish_seqstate(); diff --git a/sequencerd/sequencerd.cpp b/sequencerd/sequencerd.cpp index 841bb4f0..2e86fc50 100644 --- a/sequencerd/sequencerd.cpp +++ b/sequencerd/sequencerd.cpp @@ -129,7 +129,7 @@ int main(int argc, char **argv) { // initialize the pub/sub handler // - if ( sequencerd.sequence.init_pubsub( {"camerad"} ) == ERROR ) { + if ( sequencerd.sequence.init_pubsub( {"camerad", "slicecam_autoacq"} ) == ERROR ) { logwrite(function, "ERROR initializing publisher-subscriber handler"); sequencerd.exit_cleanly(); } diff --git a/slicecamd/CMakeLists.txt b/slicecamd/CMakeLists.txt index ecfcfcd0..12616a95 100644 --- a/slicecamd/CMakeLists.txt +++ b/slicecamd/CMakeLists.txt @@ -7,6 +7,7 @@ cmake_minimum_required( VERSION 3.12 ) set( SLICECAMD_DIR ${PROJECT_BASE_DIR}/slicecamd ) +set( AUTOACQ_SRC ${SLICECAMD_DIR}/ngps_acq.c ) set( CMAKE_CXX_STANDARD 17 ) @@ -25,6 +26,7 @@ find_library( ZMQ_LIB zmq NAMES libzmq PATHS /usr/local/lib ) # find_library( CCFITS_LIB CCfits NAMES libCCfits PATHS /usr/local/lib ) find_library( CFITS_LIB cfitsio NAMES libcfitsio PATHS /usr/local/lib ) +find_library( WCS_LIB wcs NAMES libwcs PATHS /usr/local/lib /opt/homebrew/lib ) include_directories( ${PROJECT_BASE_DIR}/utils ) include_directories( ${PROJECT_BASE_DIR}/Andor ) @@ -38,8 +40,11 @@ add_executable(slicecamd ${SLICECAMD_DIR}/slicecam_server.cpp ${SLICECAMD_DIR}/slicecam_interface.cpp ${SLICECAMD_DIR}/slicecam_fits.cpp + ${AUTOACQ_SRC} ${PYTHON_DEV} ) +set_source_files_properties( ${AUTOACQ_SRC} + PROPERTIES COMPILE_FLAGS "-x c -std=gnu11" ) target_link_libraries(slicecamd andor @@ -55,10 +60,12 @@ target_link_libraries(slicecamd ${PYTHON_LIB} ${CCFITS_LIB} ${CFITS_LIB} + ${WCS_LIB} ${CMAKE_THREAD_LIBS_INIT} ) target_compile_options(slicecamd PRIVATE -g -Wall -O1 -Wno-variadic-macros -ggdb) +target_compile_definitions(slicecamd PRIVATE NGPS_ACQ_EMBEDDED=1) # -- External Definitions ------------------------------------------------------ # diff --git a/slicecamd/ngps_acq.c b/slicecamd/ngps_acq.c new file mode 100644 index 00000000..4254a09f --- /dev/null +++ b/slicecamd/ngps_acq.c @@ -0,0 +1,2455 @@ +// ngps_acq.c +// NGPS acquisition / fine-acquire helper for the slice-viewing camera. +// +// Goals: +// - robust source detection + centroiding under poor seeing +// - robust statistics before issuing any TCS offset +// - reject duplicate frames and frames taken during/just after telescope motion +// - optional "manual" frame acquisition via: scam framegrab one +// +// Build (typical on NGPS machines): +// gcc -O3 -Wall -Wextra -std=c11 -o ngps_acq ngps_acq.c -lcfitsio -lwcs -lm +// +// Example (stream mode, file updated by camera): +// ./ngps_acq --input /tmp/slicecam.fits --goal-x 512 --goal-y 512 --loop 1 \ +// --bg-x1 50 --bg-x2 950 --bg-y1 30 --bg-y2 980 --debug 1 +// +// Example (manual framegrab mode, synchronous): +// ./ngps_acq --frame-mode framegrab --framegrab-out /tmp/ngps_acq.fits \ +// --goal-x 512 --goal-y 512 --loop 1 +// +// Notes: +// - TCS command assumed: tcs native pt +// - We set native units (dra/ddec arcsec) once per run by default. +// - Commanded offsets are computed as (star - goal) (arcsec), i.e. the move +// that should place the star on the goal pixel. If your TCS sign convention +// is opposite, set --tcs-sign -1. + +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "fitsio.h" +#include "ngps_acq_embed.h" + +#ifdef __has_include +# if __has_include() +# include +# include +# else +# include +# include +# endif +#else +# include +# include +#endif + +#ifndef PATH_MAX +#define PATH_MAX 4096 +#endif + +// ROI mask bits +#define ROI_X1_SET 0x01 +#define ROI_X2_SET 0x02 +#define ROI_Y1_SET 0x04 +#define ROI_Y2_SET 0x08 + +typedef enum { + FRAME_STREAM = 0, + FRAME_FRAMEGRAB = 1 +} FrameMode; + +typedef struct { + char input[PATH_MAX]; // stream file path + + FrameMode frame_mode; + char framegrab_out[PATH_MAX]; + int framegrab_use_tmp; // if 1: write to framegrab_out then atomically rename + + double goal_x; + double goal_y; + int pixel_origin; // 0 or 1 + + // Candidate search constraints + double max_dist_pix; // circular cut around goal (pix) + double snr_thresh; // detection threshold (sigma) + int min_adjacent; // raw-pixel neighbors above raw threshold + + // Filtering for detection + double filt_sigma_pix; // Gaussian sigma for smoothing (pix) + + // Centroiding + int centroid_halfwin; // window half-width (pix) + double centroid_sigma_pix; // Gaussian window sigma (pix) + int centroid_maxiter; + double centroid_eps_pix; + + // FITS selection + int extnum; // 0=primary, 1=first extension, ... + char extname[32]; // preferred EXTNAME, empty disables + + // Background statistics ROI (inclusive; user origin) + int bg_roi_mask; + long bg_x1, bg_x2, bg_y1, bg_y2; + + // Candidate search ROI (inclusive; user origin) + int search_roi_mask; + long search_x1, search_x2, search_y1, search_y2; + + // Closed-loop / guiding wrapper + int loop; + double cadence_sec; // minimum seconds between accepted samples (stream mode) + int max_samples; // per-move gather (accepted samples) + int min_samples; // minimum before testing scatter + double prec_arcsec; // required robust scatter (MAD->sigma) per axis + double goal_arcsec; // convergence threshold on robust offset magnitude + int max_cycles; // number of move cycles + double gain; // gain applied to commanded move (0..1 recommended) + int adaptive; // if 1: adapt exposure + loop thresholds from measured source counts + double adaptive_faint; // start faint adaptation at/under this metric + double adaptive_faint_goal; // faint-mode target metric + double adaptive_bright; // start bright adaptation at/over this metric + double adaptive_bright_goal; // bright-mode target metric + + // Frame quality & safety + int reject_identical; // reject identical image signatures + int reject_after_move; // reject N new frames after any TCS move + double settle_sec; // optional sleep after move (additional to rejecting frames) + double max_move_arcsec; // safety cap; do not issue moves larger than this + int continue_on_fail; // if 0: exit on failure to build stats; if 1: keep trying + + // Offset conventions + int dra_use_cosdec; // 1: dra = dRA*cos(dec) + int tcs_sign; // multiply commanded offsets by +/-1 + + // TCS options + int tcs_set_units; // if 1: run "tcs native dra 'arcsec'" and "... ddec 'arcsec'" once + + // SCAM daemon option (guiding-friendly moves) + int use_putonslit; // if 1: call putonslit + int wait_guiding; // if 1: wait for ACAM guiding state after putonslit + double guiding_poll_sec; // polling interval for "acam acquire" + double guiding_timeout_sec; // timeout for waiting on guiding (0 = no timeout) + + // Debug + int debug; + char debug_out[PATH_MAX]; + + // General + int dry_run; + int verbose; +} AcqParams; + +typedef struct { + int found; + // peak in pixel coords (user origin) + double peak_x, peak_y; + // centroid (user origin) + double cx, cy; + // quality + double peak_val; + double peak_snr_raw; + double snr_ap; + double src_top10_mean; // mean of top 10% positive residual pixels in centroid window + double bkg; + double sigma; + // debug ROI bounds (0-based inclusive) + long rx1, rx2, ry1, ry2; // stats ROI + long sx1, sx2, sy1, sy2; // search ROI +} Detection; + +typedef struct { + int ok; + Detection det; + + // Pixel offsets star - goal (pix) + double dx_pix; + double dy_pix; + + // WCS + int wcs_ok; + double ra_goal_deg, dec_goal_deg; + double ra_star_deg, dec_star_deg; + + // Commanded offsets (arcsec) for tcs native pt + double dra_cmd_arcsec; + double ddec_cmd_arcsec; + double r_cmd_arcsec; +} FrameResult; + +typedef struct { + time_t mtime; + off_t size; + uint64_t sig; // image signature (subsampled) + int have_sig; + struct timespec t_accept; // time we accepted this frame +} FrameState; + +typedef enum { + ADAPT_MODE_BASELINE = 0, + ADAPT_MODE_FAINT = 1, + ADAPT_MODE_BRIGHT = 2 +} AdaptiveMode; + +typedef struct { + AdaptiveMode mode; + double cadence_sec; + int reject_after_move; + double prec_arcsec; + double goal_arcsec; + int apply_camera; + double exptime_sec; + int avgframes; +} AdaptiveCycleConfig; + +typedef struct { + AdaptiveMode mode; + int have_metric; + double metric_ewma; + int have_last_camera; + double last_exptime_sec; + int last_avgframes; +} AdaptiveRuntime; + +static volatile sig_atomic_t g_stop = 0; +static ngps_acq_hooks_t g_hooks; +static int g_hooks_initialized = 0; +static void on_sigint(int sig) { (void)sig; g_stop = 1; } + +static int stop_requested(void) { + if (g_stop) return 1; + if (g_hooks_initialized && g_hooks.is_stop_requested) { + return g_hooks.is_stop_requested(g_hooks.user) ? 1 : 0; + } + return 0; +} + +static void acq_vlogf(const char* fmt, va_list ap) { + va_list copy; + va_copy(copy, ap); + vfprintf(stderr, fmt, copy); + va_end(copy); + + if (g_hooks_initialized && g_hooks.log_message) { + char buffer[2048]; + va_copy(copy, ap); + vsnprintf(buffer, sizeof(buffer), fmt, copy); + va_end(copy); + g_hooks.log_message(g_hooks.user, buffer); + } +} + +static void acq_logf(const char* fmt, ...) { + va_list ap; + va_start(ap, fmt); + acq_vlogf(fmt, ap); + va_end(ap); +} + +static void die(const char* msg) { + acq_logf("FATAL: %s\n", msg); + exit(4); +} + +static void sleep_seconds(double sec) { + if (sec <= 0) return; + struct timespec ts; + ts.tv_sec = (time_t)floor(sec); + ts.tv_nsec = (long)((sec - (double)ts.tv_sec) * 1e9); + while (nanosleep(&ts, &ts) == -1 && errno == EINTR) {} +} + +static double now_monotonic_sec(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (double)ts.tv_sec + 1e-9*(double)ts.tv_nsec; +} + +static int cmp_float(const void* a, const void* b) { + float fa = *(const float*)a; + float fb = *(const float*)b; + return (fa < fb) ? -1 : (fa > fb) ? 1 : 0; +} + +static int cmp_double(const void* a, const void* b) { + double da = *(const double*)a; + double db = *(const double*)b; + return (da < db) ? -1 : (da > db) ? 1 : 0; +} + +static double wrap_dra_deg(double dra) { + while (dra > 180.0) dra -= 360.0; + while (dra < -180.0) dra += 360.0; + return dra; +} + +// Convert a user-specified ROI into clamped 0-based inclusive bounds. +// If mask is 0, returns full-frame bounds. +static void compute_roi_0based(long nx, long ny, int pixel_origin, + int mask, long ux1_in, long ux2_in, long uy1_in, long uy2_in, + long* x1, long* x2, long* y1, long* y2) +{ + long ux1 = (pixel_origin == 0) ? 0 : 1; + long ux2 = (pixel_origin == 0) ? (nx - 1) : nx; + long uy1 = (pixel_origin == 0) ? 0 : 1; + long uy2 = (pixel_origin == 0) ? (ny - 1) : ny; + + if (mask & ROI_X1_SET) ux1 = ux1_in; + if (mask & ROI_X2_SET) ux2 = ux2_in; + if (mask & ROI_Y1_SET) uy1 = uy1_in; + if (mask & ROI_Y2_SET) uy2 = uy2_in; + + long ax1 = ux1, ax2 = ux2, ay1 = uy1, ay2 = uy2; + if (pixel_origin == 1) { ax1--; ax2--; ay1--; ay2--; } + + if (ax2 < ax1) { long t=ax1; ax1=ax2; ax2=t; } + if (ay2 < ay1) { long t=ay1; ay1=ay2; ay2=t; } + + if (ax1 < 0) ax1 = 0; + if (ay1 < 0) ay1 = 0; + if (ax2 > nx-1) ax2 = nx-1; + if (ay2 > ny-1) ay2 = ny-1; + + if (ax2 < ax1) { ax1=0; ax2=nx-1; } + if (ay2 < ay1) { ay1=0; ay2=ny-1; } + + *x1=ax1; *x2=ax2; *y1=ay1; *y2=ay2; +} + +// Subsample pixels in ROI into a float array. +static float* roi_subsample(const float* img, long nx, long ny, + long x1, long x2, long y1, long y2, + long target, long* n_out) +{ + if (x1 < 0) x1 = 0; + if (y1 < 0) y1 = 0; + if (x2 > nx-1) x2 = nx-1; + if (y2 > ny-1) y2 = ny-1; + + long wx = x2 - x1 + 1; + long wy = y2 - y1 + 1; + if (wx <= 0 || wy <= 0) { *n_out = 0; return NULL; } + + long Nroi = wx * wy; + long stride = (Nroi > target) ? (Nroi / target) : 1; + if (stride < 1) stride = 1; + + long ns = (Nroi + stride - 1) / stride; + float* sample = (float*)malloc((size_t)ns * sizeof(float)); + if (!sample) die("malloc sample failed"); + + long k = 0; + long idx = 0; + for (long y = y1; y <= y2; y++) { + long row0 = y * nx; + for (long x = x1; x <= x2; x++, idx++) { + if ((idx % stride) == 0) sample[k++] = img[row0 + x]; + } + } + + *n_out = k; + return sample; +} + +// SExtractor-like background + sigma estimation within ROI: +// - initial median + MAD +// - iterative sigma-clipping around median +// - background via mode = 2.5*median - 1.5*mean (unless skewed, then median) +static void bg_sigma_sextractor_like(const float* img, long nx, long ny, + long x1, long x2, long y1, long y2, + double* bkg_out, double* sigma_out) +{ + *bkg_out = 0.0; + *sigma_out = 1.0; + + long ns = 0; + float* sample = roi_subsample(img, nx, ny, x1, x2, y1, y2, 200000, &ns); + if (!sample || ns < 64) { + if (sample) free(sample); + return; + } + + qsort(sample, (size_t)ns, sizeof(float), cmp_float); + double median = (ns % 2) ? sample[ns/2] : 0.5*(sample[ns/2 - 1] + sample[ns/2]); + + // MAD + float* dev = (float*)malloc((size_t)ns * sizeof(float)); + if (!dev) die("malloc dev failed"); + for (long i = 0; i < ns; i++) dev[i] = (float)fabs((double)sample[i] - median); + qsort(dev, (size_t)ns, sizeof(float), cmp_float); + double mad = (ns % 2) ? dev[ns/2] : 0.5*(dev[ns/2 - 1] + dev[ns/2]); + free(dev); + + double sigma = 1.4826 * mad; + if (!isfinite(sigma) || sigma <= 0) sigma = 1.0; + + // Iterative clip + const double clip = 3.0; + double mean = median; + double sigma_prev = sigma; + for (int it = 0; it < 8; it++) { + double lo = median - clip * sigma; + double hi = median + clip * sigma; + + double sum = 0.0, sum2 = 0.0; + long n = 0; + for (long i = 0; i < ns; i++) { + double v = sample[i]; + if (v < lo || v > hi) continue; + sum += v; + sum2 += v*v; + n++; + } + if (n < 32) break; + + mean = sum / (double)n; + double var = (sum2 / (double)n) - mean*mean; + if (var < 0) var = 0; + sigma = sqrt(var); + if (!isfinite(sigma) || sigma <= 0) sigma = sigma_prev; + + double rel = fabs(sigma - sigma_prev) / (sigma_prev > 0 ? sigma_prev : 1.0); + sigma_prev = sigma; + if (rel < 0.01) break; + } + + double mode = 2.5*median - 1.5*mean; + double bkg = mode; + if (sigma > 0 && (mean - median)/sigma > 0.3) bkg = median; + if (!isfinite(bkg)) bkg = median; + if (!isfinite(sigma) || sigma <= 0) sigma = 1.0; + + *bkg_out = bkg; + *sigma_out = sigma; + + free(sample); +} + +static double median_of_doubles(double* a, int n) +{ + if (n <= 0) return 0.0; + qsort(a, (size_t)n, sizeof(double), cmp_double); + return (n % 2) ? a[n/2] : 0.5*(a[n/2 - 1] + a[n/2]); +} + +static double mad_sigma_of_doubles(const double* a_in, int n, double med) +{ + if (n <= 1) return 0.0; + double* d = (double*)malloc((size_t)n * sizeof(double)); + if (!d) die("malloc mad failed"); + for (int i = 0; i < n; i++) d[i] = fabs(a_in[i] - med); + qsort(d, (size_t)n, sizeof(double), cmp_double); + double mad = (n % 2) ? d[n/2] : 0.5*(d[n/2 - 1] + d[n/2]); + free(d); + return 1.4826 * mad; +} + +// Gaussian kernel (1D) normalized to sum=1. +static double* make_gaussian_kernel(double sigma, int* radius_out) +{ + if (sigma <= 0.2) sigma = 0.2; + int r = (int)ceil(3.0*sigma); + if (r < 1) r = 1; + int len = 2*r + 1; + double* k = (double*)malloc((size_t)len * sizeof(double)); + if (!k) die("malloc kernel failed"); + + double sum = 0.0; + for (int i = -r; i <= r; i++) { + double x = (double)i / sigma; + double v = exp(-0.5 * x * x); + k[i+r] = v; + sum += v; + } + if (sum <= 0) sum = 1.0; + for (int i = 0; i < len; i++) k[i] /= sum; + + *radius_out = r; + return k; +} + +static double kernel_sum_sq(const double* k, int radius) +{ + int len = 2*radius + 1; + double s2 = 0.0; + for (int i = 0; i < len; i++) s2 += k[i]*k[i]; + return s2; +} + +// Separable convolution on a patch (w*h). Border handling: clamp. +static void convolve_separable(const float* in, float* tmp, float* out, + int w, int h, const double* k, int r) +{ + // horizontal + for (int y = 0; y < h; y++) { + const float* row = in + y*w; + float* trow = tmp + y*w; + for (int x = 0; x < w; x++) { + double acc = 0.0; + for (int dx = -r; dx <= r; dx++) { + int xx = x + dx; + if (xx < 0) xx = 0; + if (xx >= w) xx = w-1; + acc += (double)row[xx] * k[dx+r]; + } + trow[x] = (float)acc; + } + } + + // vertical + for (int y = 0; y < h; y++) { + float* orow = out + y*w; + for (int x = 0; x < w; x++) { + double acc = 0.0; + for (int dy = -r; dy <= r; dy++) { + int yy = y + dy; + if (yy < 0) yy = 0; + if (yy >= h) yy = h-1; + acc += (double)tmp[yy*w + x] * k[dy+r]; + } + orow[x] = (float)acc; + } + } +} + +// Simple debug drawing (PPM) +static void set_px(uint8_t* rgb, int w, int h, int x, int y, uint8_t r, uint8_t g, uint8_t b) +{ + if (x < 0 || y < 0 || x >= w || y >= h) return; + size_t idx = (size_t)(y*w + x) * 3; + rgb[idx+0] = r; + rgb[idx+1] = g; + rgb[idx+2] = b; +} + +static void draw_plus(uint8_t* rgb, int w, int h, int x, int y, int rad, uint8_t r, uint8_t g, uint8_t b) +{ + for (int dx = -rad; dx <= rad; dx++) set_px(rgb, w, h, x+dx, y, r,g,b); + for (int dy = -rad; dy <= rad; dy++) set_px(rgb, w, h, x, y+dy, r,g,b); +} + +static void draw_x(uint8_t* rgb, int w, int h, int x, int y, int rad, uint8_t r, uint8_t g, uint8_t b) +{ + for (int d = -rad; d <= rad; d++) { + set_px(rgb, w, h, x+d, y+d, r,g,b); + set_px(rgb, w, h, x+d, y-d, r,g,b); + } +} + +static void draw_circle(uint8_t* rgb, int w, int h, int xc, int yc, int rad, uint8_t r, uint8_t g, uint8_t b) +{ + int x = rad; + int y = 0; + int err = 0; + while (x >= y) { + set_px(rgb,w,h, xc + x, yc + y, r,g,b); + set_px(rgb,w,h, xc + y, yc + x, r,g,b); + set_px(rgb,w,h, xc - y, yc + x, r,g,b); + set_px(rgb,w,h, xc - x, yc + y, r,g,b); + set_px(rgb,w,h, xc - x, yc - y, r,g,b); + set_px(rgb,w,h, xc - y, yc - x, r,g,b); + set_px(rgb,w,h, xc + y, yc - x, r,g,b); + set_px(rgb,w,h, xc + x, yc - y, r,g,b); + y++; + if (err <= 0) { + err += 2*y + 1; + } else { + x--; + err += 2*(y - x) + 1; + } + } +} + +static void draw_line(uint8_t* rgb, int w, int h, int x0, int y0, int x1, int y1, uint8_t r, uint8_t g, uint8_t b) +{ + int dx = abs(x1 - x0), sx = (x0 < x1) ? 1 : -1; + int dy = -abs(y1 - y0), sy = (y0 < y1) ? 1 : -1; + int err = dx + dy; + while (1) { + set_px(rgb,w,h,x0,y0,r,g,b); + if (x0 == x1 && y0 == y1) break; + int e2 = 2*err; + if (e2 >= dy) { err += dy; x0 += sx; } + if (e2 <= dx) { err += dx; y0 += sy; } + } +} + +static void draw_arrow(uint8_t* rgb, int w, int h, int x0, int y0, int x1, int y1, uint8_t r, uint8_t g, uint8_t b) +{ + draw_line(rgb,w,h,x0,y0,x1,y1,r,g,b); + double ang = atan2((double)(y1 - y0), (double)(x1 - x0)); + double a1 = ang + M_PI/8.0; + double a2 = ang - M_PI/8.0; + int L = 10; + int hx1 = x1 - (int)lround(L * cos(a1)); + int hy1 = y1 - (int)lround(L * sin(a1)); + int hx2 = x1 - (int)lround(L * cos(a2)); + int hy2 = y1 - (int)lround(L * sin(a2)); + draw_line(rgb,w,h,x1,y1,hx1,hy1,r,g,b); + draw_line(rgb,w,h,x1,y1,hx2,hy2,r,g,b); +} + +static int write_debug_ppm(const char* outpath, + const float* img, long nx, + long rx1, long rx2, long ry1, long ry2, + double bkg, double sigma, double snr_thresh, + const Detection* det, + const AcqParams* p) +{ + int w = (int)(rx2 - rx1 + 1); + int h = (int)(ry2 - ry1 + 1); + if (w <= 0 || h <= 0) return 1; + + uint8_t* rgb = (uint8_t*)malloc((size_t)w * (size_t)h * 3); + if (!rgb) return 2; + + double vmin = bkg - 2.0*sigma; + double vmax = bkg + 6.0*sigma; + double inv = (vmax > vmin) ? (1.0/(vmax - vmin)) : 1.0; + + for (int yy = 0; yy < h; yy++) { + long y = ry1 + yy; + for (int xx = 0; xx < w; xx++) { + long x = rx1 + xx; + double v = img[y*nx + x]; + double t = (v - vmin) * inv; + if (t < 0) t = 0; + if (t > 1) t = 1; + uint8_t g = (uint8_t)lround(255.0 * t); + size_t idx = (size_t)(yy*w + xx) * 3; + rgb[idx+0] = g; + rgb[idx+1] = g; + rgb[idx+2] = g; + + double snr = (sigma > 0) ? ((v - bkg) / sigma) : 0; + if (snr >= snr_thresh) { + // color above-threshold pixels (cyan-ish) + rgb[idx+0] = (uint8_t)((int)rgb[idx+0] / 2); + rgb[idx+1] = 255; + rgb[idx+2] = 255; + } + } + } + + if (det && det->found) { + int px0 = (p->pixel_origin == 0) ? (int)lround(det->peak_x) : (int)lround(det->peak_x - 1.0); + int py0 = (p->pixel_origin == 0) ? (int)lround(det->peak_y) : (int)lround(det->peak_y - 1.0); + int cx0 = (p->pixel_origin == 0) ? (int)lround(det->cx) : (int)lround(det->cx - 1.0); + int cy0 = (p->pixel_origin == 0) ? (int)lround(det->cy) : (int)lround(det->cy - 1.0); + + int gx0 = (p->pixel_origin == 0) ? (int)lround(p->goal_x) : (int)lround(p->goal_x - 1.0); + int gy0 = (p->pixel_origin == 0) ? (int)lround(p->goal_y) : (int)lround(p->goal_y - 1.0); + + // shift into ROI image coordinates + int px = px0 - (int)rx1; + int py = py0 - (int)ry1; + int cx = cx0 - (int)rx1; + int cy = cy0 - (int)ry1; + int gx = gx0 - (int)rx1; + int gy = gy0 - (int)ry1; + + draw_circle(rgb, w, h, px, py, 10, 255, 50, 50); + draw_plus(rgb, w, h, cx, cy, 6, 50, 255, 50); + draw_x(rgb, w, h, gx, gy, 8, 255, 255, 0); + draw_arrow(rgb, w, h, cx, cy, gx, gy, 255, 200, 0); + } + + FILE* f = fopen(outpath, "wb"); + if (!f) { free(rgb); return 3; } + fprintf(f, "P6\n%d %d\n255\n", w, h); + fwrite(rgb, 1, (size_t)w*(size_t)h*3, f); + fclose(f); + free(rgb); + return 0; +} + +// --- FITS I/O helpers --- +static int move_to_image_hdu_by_extname(fitsfile* fptr, const char* want_extname, int* out_hdu_index, int* status) +{ + if (!want_extname || want_extname[0] == '\0') return 1; + + int nhdus = 0; + if (fits_get_num_hdus(fptr, &nhdus, status)) return 2; + + for (int hdu = 1; hdu <= nhdus; hdu++) { + int hdutype = 0; + if (fits_movabs_hdu(fptr, hdu, &hdutype, status)) return 3; + if (hdutype != IMAGE_HDU) continue; + + char extname[FLEN_VALUE] = {0}; + int keystat = 0; + if (fits_read_key(fptr, TSTRING, "EXTNAME", extname, NULL, &keystat)) { + extname[0] = '\0'; + } + + if (extname[0] && strcasecmp(extname, want_extname) == 0) { + if (out_hdu_index) *out_hdu_index = hdu; + return 0; + } + } + + return 4; +} + +static int read_fits_image_and_header(const char* path, const AcqParams* p, + float** img_out, long* nx_out, long* ny_out, + char** header_out, int* nkeys_out, + int* used_hdu_out, char* used_extname_out, size_t used_extname_sz) +{ + fitsfile* fptr = NULL; + int status = 0; + + if (fits_open_file(&fptr, path, READONLY, &status)) return status; + + int used_hdu = 1; + char used_extname[FLEN_VALUE] = {0}; + + // Prefer EXTNAME + if (p->extname[0]) { + int found_hdu = 0; + int rc = move_to_image_hdu_by_extname(fptr, p->extname, &found_hdu, &status); + if (rc == 0) { + used_hdu = found_hdu; + } else { + if (p->verbose) acq_logf( "WARNING: EXTNAME='%s' not found; falling back to extnum=%d\n", p->extname, p->extnum); + status = 0; + int hdutype = 0; + if (fits_movabs_hdu(fptr, p->extnum + 1, &hdutype, &status)) { + fits_close_file(fptr, &status); + return status; + } + used_hdu = p->extnum + 1; + } + } else { + int hdutype = 0; + if (fits_movabs_hdu(fptr, p->extnum + 1, &hdutype, &status)) { + fits_close_file(fptr, &status); + return status; + } + used_hdu = p->extnum + 1; + } + + // record EXTNAME + { + int keystat = 0; + if (fits_read_key(fptr, TSTRING, "EXTNAME", used_extname, NULL, &keystat)) { + used_extname[0] = '\0'; + } + } + + int bitpix = 0, naxis = 0; + long naxes[3] = {0,0,0}; + if (fits_get_img_param(fptr, 3, &bitpix, &naxis, naxes, &status)) { + fits_close_file(fptr, &status); + return status; + } + if (naxis < 2) { + fits_close_file(fptr, &status); + return BAD_NAXIS; + } + + long nx = naxes[0]; + long ny = naxes[1]; + + float* img = (float*)malloc((size_t)nx * (size_t)ny * sizeof(float)); + if (!img) { + fits_close_file(fptr, &status); + return MEMORY_ALLOCATION; + } + + long fpixel[3] = {1,1,1}; + long nelem = nx * ny; + int anynul = 0; + if (fits_read_pix(fptr, TFLOAT, fpixel, nelem, NULL, img, &anynul, &status)) { + free(img); + fits_close_file(fptr, &status); + return status; + } + + char* header = NULL; + int nkeys = 0; + int hstatus = 0; + if (fits_hdr2str(fptr, 1, NULL, 0, &header, &nkeys, &hstatus)) { + header = NULL; + nkeys = 0; + } + + fits_close_file(fptr, &status); + + *img_out = img; + *nx_out = nx; + *ny_out = ny; + *header_out = header; + *nkeys_out = nkeys; + if (used_hdu_out) *used_hdu_out = used_hdu; + if (used_extname_out && used_extname_sz > 0) { + snprintf(used_extname_out, used_extname_sz, "%s", used_extname); + } + + return 0; +} + +// --- WCS helpers --- +static int init_wcs_from_header(const char* header, int nkeys, struct wcsprm** wcs_out, int* nwcs_out) +{ + if (!header || nkeys <= 0) return 1; + + int relax = WCSHDR_all; + int ctrl = 2; + int nrej = 0, nwcs = 0; + struct wcsprm* wcs = NULL; + + int stat = wcspih((char*)header, nkeys, relax, ctrl, &nrej, &nwcs, &wcs); + if (stat || nwcs < 1 || !wcs) return 2; + + if (wcsset(&wcs[0])) { + wcsvfree(&nwcs, &wcs); + return 3; + } + + *wcs_out = wcs; + *nwcs_out = nwcs; + return 0; +} + +// Pixel -> (RA,Dec) degrees using WCSLIB. Inputs are FITS 1-based pixels. +static int pix2world_wcs(const struct wcsprm* wcs0, double pix_x, double pix_y, + double* ra_deg, double* dec_deg) +{ + if (!wcs0) return 1; + + double pixcrd[2] = {pix_x, pix_y}; + double imgcrd[2] = {0,0}; + double phi=0, theta=0; + double world[2] = {0,0}; + int stat[2] = {0,0}; + + int rc = wcsp2s((struct wcsprm*)wcs0, 1, 2, pixcrd, imgcrd, &phi, &theta, world, stat); + if (rc) return 2; + + *ra_deg = world[0]; + *dec_deg = world[1]; + return 0; +} + +// --- Frame signature (reject identical) --- +static uint64_t fnv1a64_init(void) { return 1469598103934665603ULL; } +static uint64_t fnv1a64_step(uint64_t h, uint64_t x) { + h ^= x; + return h * 1099511628211ULL; +} + +static uint64_t image_signature_subsample(const float* img, long nx, long ny, + long x1, long x2, long y1, long y2) +{ + if (!img || nx <= 0 || ny <= 0) return 0; + if (x1 < 0) x1 = 0; + if (y1 < 0) y1 = 0; + if (x2 > nx-1) x2 = nx-1; + if (y2 > ny-1) y2 = ny-1; + if (x2 < x1 || y2 < y1) return 0; + + long wx = x2 - x1 + 1; + long wy = y2 - y1 + 1; + long N = wx * wy; + + // Aim for ~50k samples max. + long target = 50000; + long stride = (N > target) ? (N / target) : 1; + if (stride < 1) stride = 1; + + uint64_t h = fnv1a64_init(); + long idx = 0; + for (long y = y1; y <= y2; y++) { + long row0 = y * nx; + for (long x = x1; x <= x2; x++, idx++) { + if ((idx % stride) != 0) continue; + // quantize float to int32 with mild scaling to be stable + float v = img[row0 + x]; + int32_t q = (int32_t)lrintf(v * 8.0f); + h = fnv1a64_step(h, (uint64_t)(uint32_t)q); + } + } + // Mix in ROI bounds to reduce accidental collisions + h = fnv1a64_step(h, (uint64_t)(uint32_t)x1); + h = fnv1a64_step(h, (uint64_t)(uint32_t)y1); + h = fnv1a64_step(h, (uint64_t)(uint32_t)x2); + h = fnv1a64_step(h, (uint64_t)(uint32_t)y2); + return h; +} + +// --- TCS helpers --- +static int tcs_set_native_units(int dry_run, int verbose) +{ + if (g_hooks_initialized && g_hooks.tcs_set_native_units) { + return g_hooks.tcs_set_native_units(g_hooks.user, dry_run, verbose); + } + + const char* cmd1 = "tcs native dra 'arcsec'"; + const char* cmd2 = "tcs native ddec 'arcsec'"; + if (verbose || dry_run) { + acq_logf( "TCS CMD: %s\n", cmd1); + acq_logf( "TCS CMD: %s\n", cmd2); + } + if (dry_run) return 0; + int rc1 = system(cmd1); + int rc2 = system(cmd2); + if (rc1 != 0 || rc2 != 0) { + acq_logf( "WARNING: TCS unit command failed rc1=%d rc2=%d\n", rc1, rc2); + return 1; + } + return 0; +} + +static int tcs_move_arcsec(double dra_arcsec, double ddec_arcsec, int dry_run, int verbose) +{ + if (g_hooks_initialized && g_hooks.tcs_move_arcsec) { + return g_hooks.tcs_move_arcsec(g_hooks.user, dra_arcsec, ddec_arcsec, dry_run, verbose); + } + + char cmd[512]; + snprintf(cmd, sizeof(cmd), "tcs native pt %.3f %.3f", dra_arcsec, ddec_arcsec); + if (verbose || dry_run) acq_logf( "TCS CMD: %s\n", cmd); + if (dry_run) return 0; + + int rc = system(cmd); + if (rc != 0) { + acq_logf( "WARNING: TCS command returned %d\n", rc); + return 1; + } + return 0; +} + +// --- SCAM daemon helper (guiding-friendly move) --- +static double wrap_ra_deg(double ra) +{ + // keep in [0,360) + while (ra < 0.0) ra += 360.0; + while (ra >= 360.0) ra -= 360.0; + return ra; +} + +// Convert desired PT offsets (arcsec) into a synthetic "crosshair" RA/Dec given a "slit" RA/Dec. +// This preserves robust median offset logic while still invoking putonslit (which computes PT internally). +static void slit_cross_from_offsets(const AcqParams* p, + double slit_ra_deg, double slit_dec_deg, + double dra_arcsec, double ddec_arcsec, + double* cross_ra_deg, double* cross_dec_deg) +{ + double cosdec = cos(slit_dec_deg * M_PI / 180.0); + double ddec_deg = ddec_arcsec / 3600.0; + + double dra_deg = dra_arcsec / 3600.0; + if (p->dra_use_cosdec) { + // dra_arcsec was computed as dRA*cos(dec)*3600, so invert the cos(dec) here. + double denom = (fabs(cosdec) > 1e-12) ? cosdec : (cosdec >= 0 ? 1e-12 : -1e-12); + dra_deg /= denom; + } + + *cross_dec_deg = slit_dec_deg + ddec_deg; + *cross_ra_deg = wrap_ra_deg(slit_ra_deg + dra_deg); +} + +// Hard-coded interface: call via the SCAM daemon. +// Intentionally NOT user-adjustable. +static int scam_putonslit_deg(double slit_ra_deg, double slit_dec_deg, + double cross_ra_deg, double cross_dec_deg, + int dry_run, int verbose) +{ + if (g_hooks_initialized && g_hooks.scam_putonslit_deg) { + return g_hooks.scam_putonslit_deg(g_hooks.user, + slit_ra_deg, slit_dec_deg, + cross_ra_deg, cross_dec_deg, + dry_run, verbose); + } + + char cmd[1024]; + // putonslit (all decimal degrees) + snprintf(cmd, sizeof(cmd), "scam putonslit %.10f %.10f %.10f %.10f", + slit_ra_deg, slit_dec_deg, cross_ra_deg, cross_dec_deg); + + if (verbose || dry_run) acq_logf( "SCAM CMD: %s\n", cmd); + if (dry_run) return 0; + + int rc = system(cmd); + if (rc != 0) { + acq_logf( "WARNING: putonslit command returned %d\n", rc); + return 1; + } + return 0; +} + +// --- ACAM guiding state helpers --- +static int acam_query_state(char* state, size_t state_sz, int verbose) +{ + if (g_hooks_initialized && g_hooks.acam_query_state) { + return g_hooks.acam_query_state(g_hooks.user, state, state_sz, verbose); + } + + if (!state || state_sz == 0) return 2; + state[0] = '\0'; + + FILE* fp = popen("acam acquire", "r"); + if (!fp) { + if (verbose) acq_logf( "WARNING: failed to run 'acam acquire': %s\n", strerror(errno)); + return 1; + } + + char buf[256]; + int found = 0; + while (fgets(buf, sizeof(buf), fp)) { + if (strcasestr(buf, "guiding")) { + snprintf(state, state_sz, "guiding"); + found = 1; + } else if (strcasestr(buf, "acquiring")) { + snprintf(state, state_sz, "acquiring"); + found = 1; + } + } + + int rc = pclose(fp); + if (rc != 0 && verbose) { + acq_logf( "WARNING: 'acam acquire' returned %d\n", rc); + } + + return found ? 0 : 2; +} + +static int wait_for_guiding(const AcqParams* p) +{ + if (!p->wait_guiding || p->dry_run) return 0; + + const double poll_sec = (p->guiding_poll_sec > 0) ? p->guiding_poll_sec : 1.0; + const double timeout_sec = p->guiding_timeout_sec; + + double t0 = now_monotonic_sec(); + char last_state[32] = {0}; + int warned = 0; + + for (;;) { + if (stop_requested()) return 2; + + char state[32] = {0}; + int rc = acam_query_state(state, sizeof(state), p->verbose); + if (rc == 0) { + if (strcmp(state, last_state) != 0) { + if (p->verbose) acq_logf( "ACAM state: %s\n", state); + snprintf(last_state, sizeof(last_state), "%s", state); + } + if (!strcmp(state, "guiding")) return 0; + } else { + if (!warned) { + acq_logf( "WARNING: could not determine ACAM state; will keep polling.\n"); + warned = 1; + } + } + + if (timeout_sec > 0) { + double dt = now_monotonic_sec() - t0; + if (dt >= timeout_sec) { + acq_logf( "WARNING: timed out waiting for guiding (%.1fs).\n", timeout_sec); + return 1; + } + } + + sleep_seconds(poll_sec); + } +} + + +// --- Camera command helpers --- +static int scam_framegrab_one(const char* outpath, int verbose) +{ + if (g_hooks_initialized && g_hooks.scam_framegrab_one) { + return g_hooks.scam_framegrab_one(g_hooks.user, outpath, verbose); + } + + char cmd[PATH_MAX + 128]; + // We assume scam writes the file atomically enough; if not, stream stability check handles it. + snprintf(cmd, sizeof(cmd), "scam framegrab one %s", outpath); + if (verbose) acq_logf( "CAM CMD: %s\n", cmd); + int rc = system(cmd); + if (rc != 0) { + acq_logf( "WARNING: framegrab command failed (rc=%d)\n", rc); + return 1; + } + return 0; +} + +static int scam_set_exptime(double exptime_sec, int dry_run, int verbose) +{ + if (g_hooks_initialized && g_hooks.scam_set_exptime) { + return g_hooks.scam_set_exptime(g_hooks.user, exptime_sec, dry_run, verbose); + } + + if (!isfinite(exptime_sec) || exptime_sec <= 0) return 1; + char cmd[256]; + snprintf(cmd, sizeof(cmd), "scam exptime %.3f", exptime_sec); + if (verbose || dry_run) acq_logf( "SCAM CMD: %s\n", cmd); + if (dry_run) return 0; + int rc = system(cmd); + if (rc != 0) { + acq_logf( "WARNING: scam exptime command failed (rc=%d)\n", rc); + return 1; + } + return 0; +} + +static int scam_set_avgframes(int avgframes, int dry_run, int verbose) +{ + if (g_hooks_initialized && g_hooks.scam_set_avgframes) { + return g_hooks.scam_set_avgframes(g_hooks.user, avgframes, dry_run, verbose); + } + + if (avgframes < 1) avgframes = 1; + char cmd[256]; + snprintf(cmd, sizeof(cmd), "scam avgframes %d", avgframes); + if (verbose || dry_run) acq_logf( "SCAM CMD: %s\n", cmd); + if (dry_run) return 0; + int rc = system(cmd); + if (rc != 0) { + acq_logf( "WARNING: scam avgframes command failed (rc=%d)\n", rc); + return 1; + } + return 0; +} + +static const char* adaptive_mode_name(AdaptiveMode m) +{ + if (m == ADAPT_MODE_FAINT) return "faint"; + if (m == ADAPT_MODE_BRIGHT) return "bright"; + return "baseline"; +} + +static AdaptiveMode adaptive_next_mode(AdaptiveMode current, double metric, const AcqParams* p) +{ + if (!isfinite(metric) || metric <= 0) return current; + + // Hysteresis prevents flapping when source counts jitter near thresholds. + const double faint_enter = p->adaptive_faint; + const double faint_exit = p->adaptive_faint * 1.15; + const double bright_enter = p->adaptive_bright; + const double bright_exit = p->adaptive_bright * 0.85; + + if (current == ADAPT_MODE_FAINT) { + if (metric >= bright_enter) return ADAPT_MODE_BRIGHT; + return (metric >= faint_exit) ? ADAPT_MODE_BASELINE : ADAPT_MODE_FAINT; + } + if (current == ADAPT_MODE_BRIGHT) { + if (metric <= faint_enter) return ADAPT_MODE_FAINT; + return (metric <= bright_exit) ? ADAPT_MODE_BASELINE : ADAPT_MODE_BRIGHT; + } + + if (metric <= faint_enter) return ADAPT_MODE_FAINT; + if (metric >= bright_enter) return ADAPT_MODE_BRIGHT; + return ADAPT_MODE_BASELINE; +} + +static int adaptive_bright_avg_target(double metric, double goal_metric) +{ + double ratio = (goal_metric > 0) ? (metric / goal_metric) : 1.0; + if (!isfinite(ratio)) ratio = 1.0; + if (ratio > 8.0) return 5; + if (ratio > 4.0) return 4; + if (ratio > 2.0) return 3; + if (ratio > 1.2) return 2; + return 1; +} + +static void adaptive_build_cycle_config(const AcqParams* p, const AdaptiveRuntime* rt, + AdaptiveMode mode, double metric, AdaptiveCycleConfig* cfg) +{ + memset(cfg, 0, sizeof(*cfg)); + cfg->mode = mode; + cfg->cadence_sec = p->cadence_sec; + cfg->reject_after_move = p->reject_after_move; + cfg->prec_arcsec = p->prec_arcsec; + cfg->goal_arcsec = p->goal_arcsec; + + double cur_exp = (rt && rt->have_last_camera) ? rt->last_exptime_sec : 1.0; + int cur_avg = (rt && rt->have_last_camera) ? rt->last_avgframes : 1; + if (!isfinite(cur_exp) || cur_exp <= 0) cur_exp = 1.0; + if (cur_exp < 0.1) cur_exp = 0.1; + if (cur_exp > 15.0) cur_exp = 15.0; + if (cur_avg < 1) cur_avg = 1; + if (cur_avg > 5) cur_avg = 5; + cfg->exptime_sec = cur_exp; + cfg->avgframes = cur_avg; + + if (mode == ADAPT_MODE_BASELINE) return; + + double goal_metric = (mode == ADAPT_MODE_FAINT) ? p->adaptive_faint_goal : p->adaptive_bright_goal; + if (!isfinite(goal_metric) || goal_metric <= 0) { + goal_metric = (mode == ADAPT_MODE_FAINT) ? p->adaptive_faint : p->adaptive_bright; + } + + double factor = 1.0; + if (isfinite(metric) && metric > 0 && isfinite(goal_metric) && goal_metric > 0) { + double lo = goal_metric * 0.85; + double hi = goal_metric * 1.15; + if (metric < lo || metric > hi) { + factor = sqrt(goal_metric / metric); + if (!isfinite(factor) || factor <= 0) factor = 1.0; + if (factor < 0.5) factor = 0.5; + if (factor > 2.0) factor = 2.0; + } + } + + double target_exp = cur_exp * factor; + if (target_exp < 0.1) target_exp = 0.1; + if (target_exp > 15.0) target_exp = 15.0; + + int target_avg = cur_avg; + if (mode == ADAPT_MODE_BRIGHT) { + int desired = adaptive_bright_avg_target(metric, goal_metric); + if (target_avg < desired) target_avg++; + else if (target_avg > desired) target_avg--; + } else if (mode == ADAPT_MODE_FAINT) { + if (target_avg > 1) target_avg--; + else if (target_avg < 1) target_avg++; + cfg->reject_after_move = (p->reject_after_move > 1) ? 1 : p->reject_after_move; + cfg->prec_arcsec = fmax(p->prec_arcsec, 0.20); + cfg->goal_arcsec = fmax(p->goal_arcsec, 0.30); + } + + cfg->exptime_sec = target_exp; + cfg->avgframes = target_avg; + cfg->apply_camera = (fabs(target_exp - cur_exp) >= 0.02 || target_avg != cur_avg); + + double min_cadence = cfg->exptime_sec * (double)cfg->avgframes + 0.20; + cfg->cadence_sec = fmax(cfg->cadence_sec, min_cadence); +} + +static int adaptive_apply_camera(const AcqParams* p, AdaptiveRuntime* rt, const AdaptiveCycleConfig* cfg) +{ + if (!p->adaptive) return 0; + if (!cfg->apply_camera) return 0; + + int rc = 0; + if (scam_set_exptime(cfg->exptime_sec, p->dry_run, p->verbose)) rc = 1; + if (scam_set_avgframes(cfg->avgframes, p->dry_run, p->verbose)) rc = 1; + rt->have_last_camera = 1; + rt->last_exptime_sec = cfg->exptime_sec; + rt->last_avgframes = cfg->avgframes; + return rc; +} + +static void adaptive_update_metric_and_mode(const AcqParams* p, AdaptiveRuntime* rt, double cycle_metric) +{ + if (!p->adaptive) return; + if (!isfinite(cycle_metric) || cycle_metric <= 0) return; + + if (!rt->have_metric) { + rt->metric_ewma = cycle_metric; + rt->have_metric = 1; + } else { + const double alpha = 0.35; + double lprev = log(fmax(rt->metric_ewma, 1e-6)); + double lnow = log(fmax(cycle_metric, 1e-6)); + rt->metric_ewma = exp((1.0 - alpha) * lprev + alpha * lnow); + } + + rt->mode = adaptive_next_mode(rt->mode, rt->metric_ewma, p); +} + +static void adaptive_finish_cycle(const AcqParams* p, AdaptiveRuntime* rt, + const double* metric_samp, int n) +{ + if (!p->adaptive || n <= 0) return; + + double mtmp[n]; + for (int i = 0; i < n; i++) mtmp[i] = metric_samp[i]; + double cycle_metric = median_of_doubles(mtmp, n); + + AdaptiveMode prev = rt->mode; + adaptive_update_metric_and_mode(p, rt, cycle_metric); + + if (p->verbose) { + if (prev != rt->mode) { + acq_logf( "Adaptive transition: %s -> %s cycle_metric=%.1f ewma=%.1f\n", + adaptive_mode_name(prev), adaptive_mode_name(rt->mode), + cycle_metric, rt->metric_ewma); + } else { + acq_logf( "Adaptive hold: mode=%s cycle_metric=%.1f ewma=%.1f\n", + adaptive_mode_name(rt->mode), cycle_metric, rt->metric_ewma); + } + } +} + +static double source_top_fraction_mean(const float* img, long nx, long ny, + const Detection* d, const AcqParams* p, + double frac) +{ + if (!img || !d || !p || nx <= 0 || ny <= 0) return 0.0; + if (!d->found) return 0.0; + if (!isfinite(frac) || frac <= 0 || frac > 1) frac = 0.10; + + double cx0 = (p->pixel_origin == 0) ? d->cx : (d->cx - 1.0); + double cy0 = (p->pixel_origin == 0) ? d->cy : (d->cy - 1.0); + + long xlo = (long)floor(cx0) - p->centroid_halfwin; + long xhi = (long)floor(cx0) + p->centroid_halfwin; + long ylo = (long)floor(cy0) - p->centroid_halfwin; + long yhi = (long)floor(cy0) + p->centroid_halfwin; + + if (xlo < d->sx1) xlo = d->sx1; + if (xhi > d->sx2) xhi = d->sx2; + if (ylo < d->sy1) ylo = d->sy1; + if (yhi > d->sy2) yhi = d->sy2; + + if (xhi < xlo || yhi < ylo) return 0.0; + + long maxn = (xhi - xlo + 1) * (yhi - ylo + 1); + if (maxn <= 0) return 0.0; + + float* vals = (float*)malloc((size_t)maxn * sizeof(float)); + if (!vals) return 0.0; + + long n = 0; + for (long y = ylo; y <= yhi; y++) { + long row0 = y * nx; + for (long x = xlo; x <= xhi; x++) { + double v = (double)img[row0 + x] - d->bkg; + if (v > 0) vals[n++] = (float)v; + } + } + + if (n <= 0) { + free(vals); + return 0.0; + } + + qsort(vals, (size_t)n, sizeof(float), cmp_float); + + long k = (long)ceil(frac * (double)n); + if (k < 1) k = 1; + if (k > n) k = n; + + long start = n - k; + double sum = 0.0; + for (long i = start; i < n; i++) sum += (double)vals[i]; + + free(vals); + return sum / (double)k; +} + +// --- Detection + centroiding --- +static Detection detect_star_near_goal(const float* img, long nx, long ny, const AcqParams* p) +{ + Detection d; + memset(&d, 0, sizeof(d)); + + // Stats ROI + compute_roi_0based(nx, ny, p->pixel_origin, + p->bg_roi_mask, p->bg_x1, p->bg_x2, p->bg_y1, p->bg_y2, + &d.rx1, &d.rx2, &d.ry1, &d.ry2); + + // Search ROI (defaults to stats ROI) + if (p->search_roi_mask == 0) { + d.sx1 = d.rx1; d.sx2 = d.rx2; d.sy1 = d.ry1; d.sy2 = d.ry2; + } else { + compute_roi_0based(nx, ny, p->pixel_origin, + p->search_roi_mask, p->search_x1, p->search_x2, p->search_y1, p->search_y2, + &d.sx1, &d.sx2, &d.sy1, &d.sy2); + } + + // Background and sigma from stats ROI + bg_sigma_sextractor_like(img, nx, ny, d.rx1, d.rx2, d.ry1, d.ry2, &d.bkg, &d.sigma); + if (!isfinite(d.sigma) || d.sigma <= 0) { + d.found = 0; + return d; + } + + // Goal (0-based) + double goal_x0 = (p->pixel_origin == 0) ? p->goal_x : (p->goal_x - 1.0); + double goal_y0 = (p->pixel_origin == 0) ? p->goal_y : (p->goal_y - 1.0); + + // Build detection patch (search ROI) and background-subtract + int w = (int)(d.sx2 - d.sx1 + 1); + int h = (int)(d.sy2 - d.sy1 + 1); + if (w <= 3 || h <= 3) { + d.found = 0; + return d; + } + + float* patch = (float*)malloc((size_t)w*(size_t)h*sizeof(float)); + float* tmp = (float*)malloc((size_t)w*(size_t)h*sizeof(float)); + float* filt = (float*)malloc((size_t)w*(size_t)h*sizeof(float)); + if (!patch || !tmp || !filt) die("malloc patch/tmp/filt failed"); + + for (int yy = 0; yy < h; yy++) { + long y = d.sy1 + yy; + long row0 = y * nx; + float* prow = patch + (size_t)yy*(size_t)w; + for (int xx = 0; xx < w; xx++) { + long x = d.sx1 + xx; + double v = (double)img[row0 + x] - d.bkg; + // keep negatives; filter uses them too + prow[xx] = (float)v; + } + } + + // Filter for detection + int kr = 0; + double* k = make_gaussian_kernel(p->filt_sigma_pix, &kr); + convolve_separable(patch, tmp, filt, w, h, k, kr); + double sumsq1d = kernel_sum_sq(k, kr); + free(k); + + // Detection threshold in filtered image + // For separable 2D kernel, sigma_filt = sigma_raw * sqrt(sum(K^2)) = sigma_raw * sumsq1d + double sigma_filt = d.sigma * sumsq1d; + if (!isfinite(sigma_filt) || sigma_filt <= 0) sigma_filt = d.sigma; + double thr_filt = p->snr_thresh * sigma_filt; + + // Raw threshold for adjacency check + double thr_raw = p->snr_thresh * d.sigma; + + // Search best local maximum + double best_val = -1e300; + int best_x = -1, best_y = -1; + double best_snr_raw = 0; + + for (int yy = 1; yy < h-1; yy++) { + for (int xx = 1; xx < w-1; xx++) { + float v = filt[(size_t)yy*(size_t)w + (size_t)xx]; + if ((double)v < thr_filt) continue; + + // local max in filtered + if (v < filt[(size_t)yy*(size_t)w + (size_t)(xx-1)]) continue; + if (v < filt[(size_t)yy*(size_t)w + (size_t)(xx+1)]) continue; + if (v < filt[(size_t)(yy-1)*(size_t)w + (size_t)xx]) continue; + if (v < filt[(size_t)(yy+1)*(size_t)w + (size_t)xx]) continue; + + long x0 = d.sx1 + xx; + long y0 = d.sy1 + yy; + + // within max distance from goal + double dxg = (double)x0 - goal_x0; + double dyg = (double)y0 - goal_y0; + if (hypot(dxg, dyg) > p->max_dist_pix) continue; + + // adjacency check in raw residual image (patch) + int nadj = 0; + for (int dy = -1; dy <= 1; dy++) { + for (int dx = -1; dx <= 1; dx++) { + if (dx == 0 && dy == 0) continue; + float rv = patch[(size_t)(yy+dy)*(size_t)w + (size_t)(xx+dx)]; + if ((double)rv > thr_raw) nadj++; + } + } + if (nadj < p->min_adjacent) continue; + + // best peak by raw peak value at that location (not filtered) + float rawv = patch[(size_t)yy*(size_t)w + (size_t)xx]; + double snr_here = (d.sigma > 0) ? ((double)rawv / d.sigma) : 0; + if ((double)rawv > best_val) { + best_val = (double)rawv; + best_x = (int)x0; + best_y = (int)y0; + best_snr_raw = snr_here; + } + } + } + + if (best_x < 0) { + free(patch); free(tmp); free(filt); + d.found = 0; + return d; + } + + // Iterative Gaussian-windowed centroid on raw residuals (img - bkg) + double cx = (double)best_x; + double cy = (double)best_y; + + int hw = p->centroid_halfwin; + double s2 = p->centroid_sigma_pix * p->centroid_sigma_pix; + if (s2 <= 0.1) s2 = 0.1; + + double sumI = 0, sumX = 0, sumY = 0; + for (int it = 0; it < p->centroid_maxiter; it++) { + long xlo = (long)floor(cx) - hw; + long xhi = (long)floor(cx) + hw; + long ylo = (long)floor(cy) - hw; + long yhi = (long)floor(cy) + hw; + + // clamp to search ROI + if (xlo < d.sx1) xlo = d.sx1; + if (xhi > d.sx2) xhi = d.sx2; + if (ylo < d.sy1) ylo = d.sy1; + if (yhi > d.sy2) yhi = d.sy2; + + sumI = sumX = sumY = 0.0; + for (long y = ylo; y <= yhi; y++) { + long row0 = y * nx; + for (long x = xlo; x <= xhi; x++) { + double I = (double)img[row0 + x] - d.bkg; + if (I <= 0) continue; + double dx = (double)x - cx; + double dy = (double)y - cy; + double wgt = exp(-0.5*(dx*dx + dy*dy)/s2); + double ww = wgt * I; + sumI += ww; + sumX += ww * (double)x; + sumY += ww * (double)y; + } + } + + if (sumI <= 0) break; + + double nxp = sumX / sumI; + double nyp = sumY / sumI; + + double shift = hypot(nxp - cx, nyp - cy); + cx = nxp; + cy = nyp; + + if (shift < p->centroid_eps_pix) break; + } + + if (sumI <= 0 || !isfinite(cx) || !isfinite(cy)) { + free(patch); free(tmp); free(filt); + d.found = 0; + return d; + } + + // aperture-like SNR within centroid window + long xlo = (long)floor(cx) - hw; + long xhi = (long)floor(cx) + hw; + long ylo = (long)floor(cy) - hw; + long yhi = (long)floor(cy) + hw; + if (xlo < d.sx1) xlo = d.sx1; + if (xhi > d.sx2) xhi = d.sx2; + if (ylo < d.sy1) ylo = d.sy1; + if (yhi > d.sy2) yhi = d.sy2; + + double sig_sum = 0.0; + long npix = 0; + for (long y = ylo; y <= yhi; y++) { + long row0 = y * nx; + for (long x = xlo; x <= xhi; x++) { + double I = (double)img[row0 + x] - d.bkg; + if (I <= 0) continue; + sig_sum += I; + npix++; + } + } + double noise = d.sigma * sqrt((double)(npix > 1 ? npix : 1)); + double snr_ap = (noise > 0) ? (sig_sum / noise) : 0.0; + + d.found = 1; + d.peak_val = best_val; + d.peak_snr_raw = best_snr_raw; + d.snr_ap = snr_ap; + + d.peak_x = (p->pixel_origin == 0) ? (double)best_x : (double)best_x + 1.0; + d.peak_y = (p->pixel_origin == 0) ? (double)best_y : (double)best_y + 1.0; + d.cx = (p->pixel_origin == 0) ? cx : cx + 1.0; + d.cy = (p->pixel_origin == 0) ? cy : cy + 1.0; + d.src_top10_mean = source_top_fraction_mean(img, nx, ny, &d, p, 0.10); + + free(patch); free(tmp); free(filt); + return d; +} + +// Compute a full FrameResult from an already-loaded FITS image and header. +static FrameResult solve_frame(const float* img, long nx, long ny, const char* header, int nkeys, const AcqParams* p) +{ + FrameResult r; + memset(&r, 0, sizeof(r)); + + r.det = detect_star_near_goal(img, nx, ny, p); + if (!r.det.found) { + r.ok = 0; + return r; + } + + r.dx_pix = r.det.cx - p->goal_x; + r.dy_pix = r.det.cy - p->goal_y; + + // WCS + struct wcsprm* wcs = NULL; + int nwcs = 0; + int wcs_stat = init_wcs_from_header(header, nkeys, &wcs, &nwcs); + if (wcs_stat != 0) { + r.wcs_ok = 0; + r.ok = 0; + return r; + } + + // Convert pixels to FITS 1-based for wcsp2s + double goal_x1 = (p->pixel_origin == 0) ? (p->goal_x + 1.0) : p->goal_x; + double goal_y1 = (p->pixel_origin == 0) ? (p->goal_y + 1.0) : p->goal_y; + double star_x1 = (p->pixel_origin == 0) ? (r.det.cx + 1.0) : r.det.cx; + double star_y1 = (p->pixel_origin == 0) ? (r.det.cy + 1.0) : r.det.cy; + + if (pix2world_wcs(&wcs[0], goal_x1, goal_y1, &r.ra_goal_deg, &r.dec_goal_deg) || + pix2world_wcs(&wcs[0], star_x1, star_y1, &r.ra_star_deg, &r.dec_star_deg)) { + r.wcs_ok = 0; + r.ok = 0; + wcsvfree(&nwcs, &wcs); + return r; + } + + wcsvfree(&nwcs, &wcs); + + r.wcs_ok = 1; + + // Commanded offsets: (star - goal) + double dra_deg = wrap_dra_deg(r.ra_star_deg - r.ra_goal_deg); + double ddec_deg = (r.dec_star_deg - r.dec_goal_deg); + + double cosdec = cos(r.dec_goal_deg * M_PI / 180.0); + double dra_arcsec = dra_deg * 3600.0; + if (p->dra_use_cosdec) dra_arcsec *= cosdec; + double ddec_arcsec = ddec_deg * 3600.0; + + dra_arcsec *= (double)p->tcs_sign; + ddec_arcsec *= (double)p->tcs_sign; + + r.dra_cmd_arcsec = dra_arcsec; + r.ddec_cmd_arcsec = ddec_arcsec; + r.r_cmd_arcsec = hypot(dra_arcsec, ddec_arcsec); + + // Final accept criteria for "good sample" + // - windowed SNR must be >= threshold (it is typically more stable than raw peak SNR) + if (r.det.snr_ap < p->snr_thresh) { + r.ok = 0; + return r; + } + + r.ok = 1; + return r; +} + +// --- Frame acquisition / gating --- +static int stat_file(const char* path, struct stat* st) +{ + if (stat(path, st) != 0) return 1; + if (st->st_size <= 0) return 2; + return 0; +} + +static int wait_for_stream_update(const char* path, FrameState* fs, double settle_check_sec, double cadence_sec, int verbose) +{ + // Wait until file mtime/size changes from last accepted, then is stable across settle_check_sec. + // Also enforce a minimum time between accepted frames (cadence_sec). + struct stat st1, st2; + for (;;) { + if (stop_requested()) return 2; + + if (stat_file(path, &st1) != 0) { + sleep_seconds(0.1); + continue; + } + + // Newness check + if (fs->mtime == st1.st_mtime && fs->size == st1.st_size) { + sleep_seconds(0.1); + continue; + } + + // Stability check (not writing) + sleep_seconds(settle_check_sec); + if (stat_file(path, &st2) != 0) { + sleep_seconds(0.1); + continue; + } + if (st2.st_mtime != st1.st_mtime || st2.st_size != st1.st_size) { + // still changing + sleep_seconds(0.05); + continue; + } + + // Cadence check + if (cadence_sec > 0) { + double tnow = now_monotonic_sec(); + double tlast = (double)fs->t_accept.tv_sec + 1e-9*(double)fs->t_accept.tv_nsec; + if (tlast > 0 && (tnow - tlast) < cadence_sec) { + sleep_seconds(0.05); + continue; + } + } + + // accept this as a candidate to read + if (verbose) { + acq_logf( "Frame candidate: mtime=%ld size=%ld\n", (long)st2.st_mtime, (long)st2.st_size); + } + fs->mtime = st2.st_mtime; + fs->size = st2.st_size; + clock_gettime(CLOCK_MONOTONIC, &fs->t_accept); + return 0; + } +} + +// Read next frame into memory (img/header) according to frame_mode. +// Returns 0 on success. +static int acquire_next_frame(const AcqParams* p, FrameState* fs, + double cadence_sec, + float** img_out, long* nx_out, long* ny_out, + char** header_out, int* nkeys_out) +{ + const char* path = NULL; + + if (p->frame_mode == FRAME_FRAMEGRAB) { + // Acquire synchronously via scam + if (scam_framegrab_one(p->framegrab_out, p->verbose)) return 2; + // Give filesystem a tiny moment; and then read when stable + // (framegrab should generally be complete when system() returns, but be safe) + (void)wait_for_stream_update(p->framegrab_out, fs, 0.05, 0.0, 0); + path = p->framegrab_out; + } else { + // Stream mode: wait until file updates + if (wait_for_stream_update(p->input, fs, 0.05, cadence_sec, 0)) return 3; + path = p->input; + } + + int used_hdu = 0; + char used_extname[64] = {0}; + int st = read_fits_image_and_header(path, p, img_out, nx_out, ny_out, header_out, nkeys_out, + &used_hdu, used_extname, sizeof(used_extname)); + if (st) { + acq_logf( "ERROR: CFITSIO read failed for %s (status=%d)\n", path, st); + return 4; + } + return 0; +} + +// --- CLI --- +static void usage(const char* argv0) +{ + acq_logf( + "Usage: %s --input PATH --goal-x X --goal-y Y [options]\n" + "\n" + "Core options:\n" + " --input PATH Streamed FITS file path (default /tmp/slicecam.fits)\n" + " --goal-x X --goal-y Y Goal pixel coordinates\n" + " --pixel-origin 0|1 Coordinate origin for goal/ROI (default 0)\n" + "\n" + "Frame acquisition:\n" + " --frame-mode stream|framegrab (default stream)\n" + " --framegrab-out PATH Output FITS path for framegrab (default /tmp/ngps_acq.fits)\n" + "\n" + "Detection / centroiding:\n" + " --snr S SNR threshold (sigma) (default 8)\n" + " --max-dist PIX Search radius around goal (default 200)\n" + " --min-adj N Min adjacent raw pixels above threshold (default 4)\n" + " --filt-sigma PIX Detection filter sigma (default 1.2)\n" + " --centroid-hw N Centroid half-window (default 6)\n" + " --centroid-sigma PIX Centroid window sigma (default 2.0)\n" + "\n" + "FITS selection:\n" + " --extname NAME Prefer this EXTNAME (default L). Use 'none' to disable.\n" + " --extnum N Fallback HDU index (0=primary,1=first ext...) (default 1)\n" + "\n" + "ROIs (inclusive bounds; same origin as goal):\n" + " --bg-x1 N --bg-x2 N --bg-y1 N --bg-y2 N Background/stats ROI\n" + " --search-x1 N --search-x2 N --search-y1 N --search-y2 N Search ROI (defaults to bg ROI)\n" + "\n" + "Closed-loop acquisition:\n" + " --loop 0|1 Enable closed-loop mode (default 0)\n" + " --cadence-sec S Minimum seconds between accepted frames (stream) (default 0.0)\n" + " --max-samples N Samples to collect per move (default 10)\n" + " --min-samples N Minimum before evaluating precision (default 3)\n" + " --prec-arcsec A Required robust scatter per axis (default 0.1)\n" + " --goal-arcsec A Converge threshold on |offset| (default 0.1)\n" + " --max-cycles N Move cycles (default 20)\n" + " --gain G Gain applied to move (default 0.7)\n" + " --adaptive 0|1 Adaptive extremes-only mode (default 0)\n" + " --adaptive-faint X Start faint adaptation when metric <= X (default 500)\n" + " --adaptive-faint-goal X Faint-mode target metric (default 500)\n" + " --adaptive-bright Y Start bright adaptation when metric >= Y (default 10000)\n" + " --adaptive-bright-goal Y Bright-mode target metric (default 10000)\n" + " Metric is top-10%% mean source counts (background-subtracted).\n" + " In-range [faint,bright] keeps baseline behavior.\n" + "\n" + "Robustness / safety:\n" + " --reject-identical 0|1 Reject identical frames (default 1)\n" + " --reject-after-move N Reject N new frames after move (default 2)\n" + " --settle-sec S Sleep after move (default 0.0)\n" + " --max-move-arcsec A Do not issue moves larger than this (default 10)\n" + " --continue-on-fail 0|1 If 0: exit on failure; if 1: keep trying (default 0)\n" + "\n" + "TCS conventions:\n" + " --tcs-set-units 0|1 Set native dra/ddec units to arcsec once (default 1)\n" + " --use-putonslit 0|1 Use scam daemon putonslit for moves (default 0)\n" + " --dra-use-cosdec 0|1 dra = dRA*cos(dec) (default 1)\n" + " --tcs-sign +1|-1 Multiply commanded offsets by sign (default +1)\n" + "\n" + "Guiding wait (after putonslit):\n" + " --wait-guiding 0|1 Wait for ACAM guiding state after putonslit (default 1)\n" + " --guiding-poll-sec S Poll period for 'acam acquire' (default 1.0)\n" + " --guiding-timeout-sec S Timeout waiting for guiding; 0=wait forever (default 120)\n" + "\n" + "Debug:\n" + " --debug 0|1 Write overlay PPM (default 0)\n" + " --debug-out PATH PPM path (default ./ngps_acq_debug.ppm)\n" + "\n" + "General:\n" + " --dry-run 0|1 Do not call TCS (default 0)\n" + " --verbose 0|1 Verbose logs (default 1)\n", + argv0 + ); +} + +static void set_defaults(AcqParams* p) +{ + memset(p, 0, sizeof(*p)); + snprintf(p->input, sizeof(p->input), "/tmp/slicecam.fits"); + p->frame_mode = FRAME_STREAM; + snprintf(p->framegrab_out, sizeof(p->framegrab_out), "/tmp/ngps_acq.fits"); + p->framegrab_use_tmp = 0; + + p->goal_x = 0; + p->goal_y = 0; + p->pixel_origin = 0; + + p->max_dist_pix = 200.0; + p->snr_thresh = 8.0; + p->min_adjacent = 4; + + p->filt_sigma_pix = 1.2; + + p->centroid_halfwin = 6; + p->centroid_sigma_pix = 2.0; + p->centroid_maxiter = 12; + p->centroid_eps_pix = 0.01; + + p->extnum = 1; + snprintf(p->extname, sizeof(p->extname), "L"); + + p->bg_roi_mask = 0; + p->search_roi_mask = 0; + + p->loop = 0; + p->cadence_sec = 0.0; + p->max_samples = 10; + p->min_samples = 3; + p->prec_arcsec = 0.1; + p->goal_arcsec = 0.1; + p->max_cycles = 20; + p->gain = 0.7; + p->adaptive = 0; + p->adaptive_faint = 500.0; + p->adaptive_faint_goal = 500.0; + p->adaptive_bright = 10000.0; + p->adaptive_bright_goal = 10000.0; + + p->reject_identical = 1; + p->reject_after_move = 2; + p->settle_sec = 0.0; + p->max_move_arcsec = 10.0; + p->continue_on_fail = 0; + + p->dra_use_cosdec = 1; + p->tcs_sign = +1; + + p->tcs_set_units = 1; + + p->use_putonslit = 0; + p->wait_guiding = 1; + p->guiding_poll_sec = 1.0; + p->guiding_timeout_sec = 120.0; + + p->debug = 0; + snprintf(p->debug_out, sizeof(p->debug_out), "./ngps_acq_debug.ppm"); + + p->dry_run = 0; + p->verbose = 1; +} + +static int parse_args(int argc, char** argv, AcqParams* p) +{ + for (int i = 1; i < argc; i++) { + const char* a = argv[i]; + if (!strcmp(a, "--input") && i+1 < argc) { + snprintf(p->input, sizeof(p->input), "%s", argv[++i]); + } else if (!strcmp(a, "--goal-x") && i+1 < argc) { + p->goal_x = atof(argv[++i]); + } else if (!strcmp(a, "--goal-y") && i+1 < argc) { + p->goal_y = atof(argv[++i]); + } else if (!strcmp(a, "--pixel-origin") && i+1 < argc) { + p->pixel_origin = atoi(argv[++i]); + + } else if (!strcmp(a, "--frame-mode") && i+1 < argc) { + const char* m = argv[++i]; + if (!strcasecmp(m, "stream")) p->frame_mode = FRAME_STREAM; + else if (!strcasecmp(m, "framegrab")) p->frame_mode = FRAME_FRAMEGRAB; + else { acq_logf( "Invalid --frame-mode: %s\n", m); return -1; } + } else if (!strcmp(a, "--framegrab-out") && i+1 < argc) { + snprintf(p->framegrab_out, sizeof(p->framegrab_out), "%s", argv[++i]); + + } else if (!strcmp(a, "--max-dist") && i+1 < argc) { + p->max_dist_pix = atof(argv[++i]); + } else if (!strcmp(a, "--snr") && i+1 < argc) { + p->snr_thresh = atof(argv[++i]); + } else if (!strcmp(a, "--min-adj") && i+1 < argc) { + p->min_adjacent = atoi(argv[++i]); + } else if (!strcmp(a, "--filt-sigma") && i+1 < argc) { + p->filt_sigma_pix = atof(argv[++i]); + } else if (!strcmp(a, "--centroid-hw") && i+1 < argc) { + p->centroid_halfwin = atoi(argv[++i]); + } else if (!strcmp(a, "--centroid-sigma") && i+1 < argc) { + p->centroid_sigma_pix = atof(argv[++i]); + + } else if (!strcmp(a, "--extnum") && i+1 < argc) { + p->extnum = atoi(argv[++i]); + } else if (!strcmp(a, "--extname") && i+1 < argc) { + snprintf(p->extname, sizeof(p->extname), "%s", argv[++i]); + if (!strcasecmp(p->extname, "none")) p->extname[0] = '\0'; + + } else if (!strcmp(a, "--bg-x1") && i+1 < argc) { + p->bg_x1 = atol(argv[++i]); p->bg_roi_mask |= ROI_X1_SET; + } else if (!strcmp(a, "--bg-x2") && i+1 < argc) { + p->bg_x2 = atol(argv[++i]); p->bg_roi_mask |= ROI_X2_SET; + } else if (!strcmp(a, "--bg-y1") && i+1 < argc) { + p->bg_y1 = atol(argv[++i]); p->bg_roi_mask |= ROI_Y1_SET; + } else if (!strcmp(a, "--bg-y2") && i+1 < argc) { + p->bg_y2 = atol(argv[++i]); p->bg_roi_mask |= ROI_Y2_SET; + + } else if (!strcmp(a, "--search-x1") && i+1 < argc) { + p->search_x1 = atol(argv[++i]); p->search_roi_mask |= ROI_X1_SET; + } else if (!strcmp(a, "--search-x2") && i+1 < argc) { + p->search_x2 = atol(argv[++i]); p->search_roi_mask |= ROI_X2_SET; + } else if (!strcmp(a, "--search-y1") && i+1 < argc) { + p->search_y1 = atol(argv[++i]); p->search_roi_mask |= ROI_Y1_SET; + } else if (!strcmp(a, "--search-y2") && i+1 < argc) { + p->search_y2 = atol(argv[++i]); p->search_roi_mask |= ROI_Y2_SET; + + } else if (!strcmp(a, "--loop") && i+1 < argc) { + p->loop = atoi(argv[++i]); + } else if (!strcmp(a, "--cadence-sec") && i+1 < argc) { + p->cadence_sec = atof(argv[++i]); + } else if (!strcmp(a, "--max-samples") && i+1 < argc) { + p->max_samples = atoi(argv[++i]); + } else if (!strcmp(a, "--min-samples") && i+1 < argc) { + p->min_samples = atoi(argv[++i]); + } else if (!strcmp(a, "--prec-arcsec") && i+1 < argc) { + p->prec_arcsec = atof(argv[++i]); + } else if (!strcmp(a, "--goal-arcsec") && i+1 < argc) { + p->goal_arcsec = atof(argv[++i]); + } else if (!strcmp(a, "--max-cycles") && i+1 < argc) { + p->max_cycles = atoi(argv[++i]); + } else if (!strcmp(a, "--gain") && i+1 < argc) { + p->gain = atof(argv[++i]); + } else if (!strcmp(a, "--adaptive") && i+1 < argc) { + p->adaptive = atoi(argv[++i]); + } else if (!strcmp(a, "--adaptive-faint") && i+1 < argc) { + p->adaptive_faint = atof(argv[++i]); + } else if (!strcmp(a, "--adaptive-faint-goal") && i+1 < argc) { + p->adaptive_faint_goal = atof(argv[++i]); + } else if (!strcmp(a, "--adaptive-bright") && i+1 < argc) { + p->adaptive_bright = atof(argv[++i]); + } else if (!strcmp(a, "--adaptive-bright-goal") && i+1 < argc) { + p->adaptive_bright_goal = atof(argv[++i]); + + } else if (!strcmp(a, "--reject-identical") && i+1 < argc) { + p->reject_identical = atoi(argv[++i]); + } else if (!strcmp(a, "--reject-after-move") && i+1 < argc) { + p->reject_after_move = atoi(argv[++i]); + } else if (!strcmp(a, "--settle-sec") && i+1 < argc) { + p->settle_sec = atof(argv[++i]); + } else if (!strcmp(a, "--max-move-arcsec") && i+1 < argc) { + p->max_move_arcsec = atof(argv[++i]); + } else if (!strcmp(a, "--continue-on-fail") && i+1 < argc) { + p->continue_on_fail = atoi(argv[++i]); + + } else if (!strcmp(a, "--dra-use-cosdec") && i+1 < argc) { + p->dra_use_cosdec = atoi(argv[++i]); + } else if (!strcmp(a, "--tcs-sign") && i+1 < argc) { + p->tcs_sign = atoi(argv[++i]); + if (!(p->tcs_sign == 1 || p->tcs_sign == -1)) { acq_logf( "--tcs-sign must be +1 or -1\n"); return -1; } + } else if (!strcmp(a, "--tcs-set-units") && i+1 < argc) { + p->tcs_set_units = atoi(argv[++i]); + + } else if (!strcmp(a, "--use-putonslit") && i+1 < argc) { + p->use_putonslit = atoi(argv[++i]); + + } else if (!strcmp(a, "--wait-guiding") && i+1 < argc) { + p->wait_guiding = atoi(argv[++i]); + } else if (!strcmp(a, "--guiding-poll-sec") && i+1 < argc) { + p->guiding_poll_sec = atof(argv[++i]); + } else if (!strcmp(a, "--guiding-timeout-sec") && i+1 < argc) { + p->guiding_timeout_sec = atof(argv[++i]); + + } else if (!strcmp(a, "--debug") && i+1 < argc) { + p->debug = atoi(argv[++i]); + } else if (!strcmp(a, "--debug-out") && i+1 < argc) { + snprintf(p->debug_out, sizeof(p->debug_out), "%s", argv[++i]); + + } else if (!strcmp(a, "--dry-run") && i+1 < argc) { + p->dry_run = atoi(argv[++i]); + } else if (!strcmp(a, "--verbose") && i+1 < argc) { + p->verbose = atoi(argv[++i]); + + } else if (!strcmp(a, "--help") || !strcmp(a, "-h")) { + return 0; + } else { + acq_logf( "Unknown/invalid arg: %s\n", a); + return -1; + } + } + + if (!(p->pixel_origin == 0 || p->pixel_origin == 1)) { + acq_logf( "--pixel-origin must be 0 or 1\n"); + return -1; + } + if (!isfinite(p->goal_x) || !isfinite(p->goal_y)) { + acq_logf( "You must provide --goal-x and --goal-y\n"); + return -1; + } + if (p->max_samples < 1) p->max_samples = 1; + if (p->min_samples < 1) p->min_samples = 1; + if (p->min_samples > p->max_samples) p->min_samples = p->max_samples; + if (p->gain < 0) p->gain = 0; + if (p->gain > 1.5) p->gain = 1.5; + if (!(p->adaptive == 0 || p->adaptive == 1)) { + acq_logf( "--adaptive must be 0 or 1\n"); + return -1; + } + if (!isfinite(p->adaptive_faint) || p->adaptive_faint <= 0) { + acq_logf( "--adaptive-faint must be > 0\n"); + return -1; + } + if (!isfinite(p->adaptive_bright) || p->adaptive_bright <= p->adaptive_faint) { + acq_logf( "--adaptive-bright must be > --adaptive-faint\n"); + return -1; + } + if (!isfinite(p->adaptive_faint_goal) || p->adaptive_faint_goal <= 0) { + acq_logf( "--adaptive-faint-goal must be > 0\n"); + return -1; + } + if (!isfinite(p->adaptive_bright_goal) || p->adaptive_bright_goal <= 0) { + acq_logf( "--adaptive-bright-goal must be > 0\n"); + return -1; + } + if (p->max_move_arcsec <= 0) p->max_move_arcsec = 10.0; + if (p->reject_after_move < 0) p->reject_after_move = 0; + if (p->cadence_sec < 0) p->cadence_sec = 0; + if (p->filt_sigma_pix <= 0) p->filt_sigma_pix = 1.2; + if (p->centroid_sigma_pix <= 0) p->centroid_sigma_pix = 2.0; + if (p->centroid_halfwin < 2) p->centroid_halfwin = 2; + if (p->guiding_poll_sec <= 0) p->guiding_poll_sec = 1.0; + if (p->guiding_timeout_sec < 0) p->guiding_timeout_sec = 0; + + if (p->use_putonslit) { + // putonslit computes PT internally; no need to set native dra/ddec units. + p->tcs_set_units = 0; + } + + return 1; +} + +// --- One-shot processing --- +static int process_once(const AcqParams* p, FrameState* fs) +{ + float* img = NULL; + long nx=0, ny=0; + char* header = NULL; + int nkeys = 0; + + int rc = acquire_next_frame(p, fs, p->cadence_sec, &img, &nx, &ny, &header, &nkeys); + if (rc) { + if (img) free(img); + if (header) free(header); + return 4; + } + + // signature for rejecting identical frames + if (p->reject_identical) { + long sx1,sx2,sy1,sy2; + compute_roi_0based(nx, ny, p->pixel_origin, + p->bg_roi_mask, p->bg_x1, p->bg_x2, p->bg_y1, p->bg_y2, + &sx1,&sx2,&sy1,&sy2); + uint64_t sig = image_signature_subsample(img, nx, ny, sx1,sx2,sy1,sy2); + if (fs->have_sig && sig == fs->sig) { + if (p->verbose) acq_logf( "Duplicate frame signature (reject).\n"); + free(img); + if (header) free(header); + return 2; + } + fs->sig = sig; + fs->have_sig = 1; + } + + FrameResult fr = solve_frame(img, nx, ny, header, nkeys, p); + + if (p->debug) { + (void)write_debug_ppm(p->debug_out, img, nx, + fr.det.rx1, fr.det.rx2, fr.det.ry1, fr.det.ry2, + fr.det.bkg, fr.det.sigma, p->snr_thresh, + &fr.det, p); + } + + free(img); + if (header) free(header); + + if (!fr.ok) { + if (p->verbose) acq_logf( "No valid solution (star/WCS/quality).\n"); + return 2; + } + + if (p->verbose) { + acq_logf( "Centroid=(%.3f,%.3f) dx=%.3f dy=%.3f pix SNR_ap=%.2f\n", + fr.det.cx, fr.det.cy, fr.dx_pix, fr.dy_pix, fr.det.snr_ap); + if (p->adaptive) { + acq_logf( "Source metric (top10%% mean count): %.1f\n", fr.det.src_top10_mean); + } + acq_logf( "Command offsets (arcsec): dra=%.3f ddec=%.3f r=%.3f\n", + fr.dra_cmd_arcsec, fr.ddec_cmd_arcsec, fr.r_cmd_arcsec); + } + + // Machine-readable line + printf("NGPS_ACQ_RESULT ok=1 cx=%.6f cy=%.6f dra_arcsec=%.6f ddec_arcsec=%.6f r_arcsec=%.6f snr_ap=%.3f\n", + fr.det.cx, fr.det.cy, fr.dra_cmd_arcsec, fr.ddec_cmd_arcsec, fr.r_cmd_arcsec, fr.det.snr_ap); + + // Safety: do not move without stats in loop mode; in one-shot we do a single move only if --loop=0 + if (!p->loop) { + if (fr.r_cmd_arcsec > p->max_move_arcsec) { + acq_logf( "REFUSE MOVE: |offset|=%.3f"" exceeds --max-move-arcsec=%.3f\n", fr.r_cmd_arcsec, p->max_move_arcsec); + return 3; + } + + double dra = p->gain * fr.dra_cmd_arcsec; + double ddec = p->gain * fr.ddec_cmd_arcsec; + + if (p->use_putonslit) { + if (!fr.wcs_ok) { + acq_logf( "REFUSE MOVE: WCS not available for putonslit\n"); + return 3; + } + double cross_ra=0.0, cross_dec=0.0; + slit_cross_from_offsets(p, fr.ra_goal_deg, fr.dec_goal_deg, dra, ddec, &cross_ra, &cross_dec); + (void)scam_putonslit_deg(fr.ra_goal_deg, fr.dec_goal_deg, cross_ra, cross_dec, p->dry_run, p->verbose); + (void)wait_for_guiding(p); + } else { + (void)tcs_move_arcsec(dra, ddec, p->dry_run, p->verbose); + } + } + + return 0; +} + +// --- Closed-loop acquisition --- +static int run_loop(const AcqParams* p) +{ + FrameState fs; + memset(&fs, 0, sizeof(fs)); + fs.t_accept.tv_sec = 0; + fs.t_accept.tv_nsec = 0; + + if (!p->use_putonslit && p->tcs_set_units) (void)tcs_set_native_units(p->dry_run, p->verbose); + + int skip_after_move = 0; + AdaptiveRuntime adaptive_rt; + memset(&adaptive_rt, 0, sizeof(adaptive_rt)); + adaptive_rt.mode = ADAPT_MODE_BASELINE; + + for (int cycle = 1; cycle <= p->max_cycles && !stop_requested(); cycle++) { + if (p->verbose) acq_logf( "\n=== Cycle %d/%d ===\n", cycle, p->max_cycles); + + AdaptiveCycleConfig cycle_cfg; + adaptive_build_cycle_config(p, &adaptive_rt, ADAPT_MODE_BASELINE, 0.0, &cycle_cfg); + if (p->adaptive) { + double metric_cfg = adaptive_rt.have_metric ? adaptive_rt.metric_ewma : 0.0; + adaptive_build_cycle_config(p, &adaptive_rt, adaptive_rt.mode, metric_cfg, &cycle_cfg); + (void)adaptive_apply_camera(p, &adaptive_rt, &cycle_cfg); + if (p->verbose) { + if (adaptive_rt.have_metric) { + acq_logf( + "Adaptive mode=%s ewma=%.1f exp=%.2fs avg=%d cadence=%.2fs reject_after_move=%d prec=%.3f\" goal=%.3f\"\n", + adaptive_mode_name(adaptive_rt.mode), adaptive_rt.metric_ewma, + cycle_cfg.exptime_sec, cycle_cfg.avgframes, + cycle_cfg.cadence_sec, cycle_cfg.reject_after_move, + cycle_cfg.prec_arcsec, cycle_cfg.goal_arcsec); + } else { + acq_logf( + "Adaptive mode=%s exp=%.2fs avg=%d cadence=%.2fs reject_after_move=%d prec=%.3f\" goal=%.3f\"\n", + adaptive_mode_name(adaptive_rt.mode), cycle_cfg.exptime_sec, cycle_cfg.avgframes, + cycle_cfg.cadence_sec, cycle_cfg.reject_after_move, + cycle_cfg.prec_arcsec, cycle_cfg.goal_arcsec); + } + } + } + + double runtime_cadence_sec = cycle_cfg.cadence_sec; + int runtime_reject_after_move = cycle_cfg.reject_after_move; + double runtime_prec_arcsec = cycle_cfg.prec_arcsec; + double runtime_goal_arcsec = cycle_cfg.goal_arcsec; + + double dra_samp[p->max_samples]; + double ddec_samp[p->max_samples]; + double metric_samp[p->max_samples]; + int n = 0; + + double slit_ra_deg = 0.0, slit_dec_deg = 0.0; + int have_slit = 0; + + int attempts = 0; + int max_attempts = p->max_samples * 10; + + while (n < p->max_samples && attempts < max_attempts && !stop_requested()) { + attempts++; + + float* img = NULL; + long nx=0, ny=0; + char* header = NULL; + int nkeys = 0; + + int rc = acquire_next_frame(p, &fs, runtime_cadence_sec, &img, &nx, &ny, &header, &nkeys); + if (rc) { + if (img) free(img); + if (header) free(header); + acq_logf( "WARNING: failed to acquire frame (rc=%d)\n", rc); + sleep_seconds(0.1); + continue; + } + + // signature-based duplicate reject (compute on stats ROI) + if (p->reject_identical) { + long sx1,sx2,sy1,sy2; + compute_roi_0based(nx, ny, p->pixel_origin, + p->bg_roi_mask, p->bg_x1, p->bg_x2, p->bg_y1, p->bg_y2, + &sx1,&sx2,&sy1,&sy2); + uint64_t sig = image_signature_subsample(img, nx, ny, sx1,sx2,sy1,sy2); + if (fs.have_sig && sig == fs.sig) { + if (p->verbose) acq_logf( "Reject: identical frame signature\n"); + free(img); + if (header) free(header); + continue; + } + fs.sig = sig; + fs.have_sig = 1; + } + + if (skip_after_move > 0) { + skip_after_move--; + if (p->verbose) acq_logf( "Reject: post-move frame (%d remaining)\n", skip_after_move); + free(img); + if (header) free(header); + continue; + } + + FrameResult fr = solve_frame(img, nx, ny, header, nkeys, p); + + if (p->debug) { + (void)write_debug_ppm(p->debug_out, img, nx, + fr.det.rx1, fr.det.rx2, fr.det.ry1, fr.det.ry2, + fr.det.bkg, fr.det.sigma, p->snr_thresh, + &fr.det, p); + } + + free(img); + if (header) free(header); + + if (!fr.ok) { + if (p->verbose) acq_logf( "Reject: no valid solution (SNR/WCS/star).\n"); + continue; + } + + if (fr.r_cmd_arcsec > p->max_move_arcsec) { + acq_logf( "Reject: |offset|=%.3f"" exceeds --max-move-arcsec=%.3f\n", fr.r_cmd_arcsec, p->max_move_arcsec); + continue; + } + + metric_samp[n] = fr.det.src_top10_mean; + dra_samp[n] = fr.dra_cmd_arcsec; + ddec_samp[n] = fr.ddec_cmd_arcsec; + slit_ra_deg = fr.ra_goal_deg; + slit_dec_deg = fr.dec_goal_deg; + have_slit = 1; + n++; + + // Compute robust stats on the fly + double dra_tmp[p->max_samples]; + double ddec_tmp[p->max_samples]; + for (int i = 0; i < n; i++) { dra_tmp[i]=dra_samp[i]; ddec_tmp[i]=ddec_samp[i]; } + double med_dra = median_of_doubles(dra_tmp, n); + double med_ddec = median_of_doubles(ddec_tmp, n); + double sig_dra = mad_sigma_of_doubles(dra_samp, n, med_dra); + double sig_ddec = mad_sigma_of_doubles(ddec_samp, n, med_ddec); + double rmed = hypot(med_dra, med_ddec); + + if (p->verbose) { + if (p->adaptive) { + acq_logf( "Sample %d/%d: dra=%.3f"" ddec=%.3f"" |med|=%.3f"" scatter(MAD)=(%.3f,%.3f)"" metric=%.1f mode=%s\n", + n, p->max_samples, dra_samp[n-1], ddec_samp[n-1], rmed, sig_dra, sig_ddec, + fr.det.src_top10_mean, adaptive_mode_name(adaptive_rt.mode)); + } else { + acq_logf( "Sample %d/%d: dra=%.3f"" ddec=%.3f"" |med|=%.3f"" scatter(MAD)=(%.3f,%.3f)""\n", + n, p->max_samples, dra_samp[n-1], ddec_samp[n-1], rmed, sig_dra, sig_ddec); + } + } + + // If already within goal threshold, finish (no move) + if (n >= p->min_samples && rmed <= runtime_goal_arcsec) { + if (p->verbose) acq_logf( "Converged: |median offset|=%.3f"" <= %.3f""\n", rmed, runtime_goal_arcsec); + return 0; + } + + // If centroiding precision is good enough, we can move now + if (n >= p->min_samples && sig_dra <= runtime_prec_arcsec && sig_ddec <= runtime_prec_arcsec) { + // Issue robust move + double cmd_dra = p->gain * med_dra; + double cmd_ddec = p->gain * med_ddec; + + if (p->verbose) { + acq_logf( "MOVE (robust median): dra=%.3f"" ddec=%.3f"" (gain=%.3f)\n", cmd_dra, cmd_ddec, p->gain); + } + + if (p->use_putonslit) { + if (!have_slit) { + acq_logf( "REFUSE MOVE: missing slit RA/Dec for putonslit\n"); + } else { + double cross_ra=0.0, cross_dec=0.0; + slit_cross_from_offsets(p, slit_ra_deg, slit_dec_deg, cmd_dra, cmd_ddec, &cross_ra, &cross_dec); + (void)scam_putonslit_deg(slit_ra_deg, slit_dec_deg, cross_ra, cross_dec, p->dry_run, p->verbose); + (void)wait_for_guiding(p); + } + } else { + (void)tcs_move_arcsec(cmd_dra, cmd_ddec, p->dry_run, p->verbose); + } + + // After move, reject a few new frames to avoid trailed exposures. + adaptive_finish_cycle(p, &adaptive_rt, metric_samp, n); + if (p->settle_sec > 0) sleep_seconds(p->settle_sec); + skip_after_move = runtime_reject_after_move; + + // proceed to next cycle + goto next_cycle; + } + } + + // If we get here, we did not reach required precision. + if (n >= p->min_samples) { + double dra_tmp[p->max_samples]; + double ddec_tmp[p->max_samples]; + for (int i = 0; i < n; i++) { dra_tmp[i]=dra_samp[i]; ddec_tmp[i]=ddec_samp[i]; } + double med_dra = median_of_doubles(dra_tmp, n); + double med_ddec = median_of_doubles(ddec_tmp, n); + double sig_dra = mad_sigma_of_doubles(dra_samp, n, med_dra); + double sig_ddec = mad_sigma_of_doubles(ddec_samp, n, med_ddec); + double rmed = hypot(med_dra, med_ddec); + + acq_logf( "FAIL: insufficient precision to move safely after %d samples (attempts=%d).\n", n, attempts); + acq_logf( " median(dra,ddec)=(%.3f,%.3f)"" scatter(MAD)=(%.3f,%.3f)"" |med|=%.3f""\n", + med_dra, med_ddec, sig_dra, sig_ddec, rmed); + } else { + acq_logf( "FAIL: too few valid samples (n=%d) after attempts=%d.\n", n, attempts); + } + + adaptive_finish_cycle(p, &adaptive_rt, metric_samp, n); + + if (!p->continue_on_fail) return 2; + +next_cycle: + ; + } + + if (stop_requested()) { + acq_logf( "Stopped by user request.\n"); + return 1; + } + + acq_logf( "FAILED: reached max cycles (%d) without convergence.\n", p->max_cycles); + return 1; +} + +#ifdef __cplusplus +extern "C" { +#endif + +void ngps_acq_set_hooks(const ngps_acq_hooks_t* hooks) { + if (!hooks) { + memset(&g_hooks, 0, sizeof(g_hooks)); + g_hooks_initialized = 0; + return; + } + + g_hooks = *hooks; + g_hooks_initialized = 1; +} + +void ngps_acq_clear_hooks(void) { + memset(&g_hooks, 0, sizeof(g_hooks)); + g_hooks_initialized = 0; +} + +void ngps_acq_request_stop(int stop_requested_flag) { + g_stop = stop_requested_flag ? 1 : 0; +} + +static int ngps_acq_run_internal(int argc, char** argv, int install_signal_handler) +{ + g_stop = 0; + if (install_signal_handler) signal(SIGINT, on_sigint); + + AcqParams p; + set_defaults(&p); + + int pr = parse_args(argc, argv, &p); + if (pr <= 0) { usage(argv[0]); return (pr == 0) ? 0 : 4; } + + if (p.verbose) { + acq_logf( + "NGPS ACQ start:\n" + " mode=%s input=%s framegrab_out=%s\n" + " goal=(%.3f,%.3f) origin=%d max_dist=%.1f snr=%.1f filt_sigma=%.2f\n" + " centroid_hw=%d centroid_sigma=%.2f\n" + " loop=%d cadence=%.2fs max_samples=%d min_samples=%d prec=%.3f\" goal=%.3f\" gain=%.2f\n" + " reject_identical=%d reject_after_move=%d settle=%.2fs max_move=%.2f\"\n" + " use_putonslit=%d wait_guiding=%d guiding_poll=%.2fs guiding_timeout=%.1fs\n" + " tcs_set_units=%d dra_use_cosdec=%d tcs_sign=%d dry_run=%d\n", + (p.frame_mode == FRAME_FRAMEGRAB) ? "framegrab" : "stream", + p.input, p.framegrab_out, + p.goal_x, p.goal_y, p.pixel_origin, p.max_dist_pix, p.snr_thresh, p.filt_sigma_pix, + p.centroid_halfwin, p.centroid_sigma_pix, + p.loop, p.cadence_sec, p.max_samples, p.min_samples, p.prec_arcsec, p.goal_arcsec, p.gain, + p.reject_identical, p.reject_after_move, p.settle_sec, p.max_move_arcsec, + p.use_putonslit, p.wait_guiding, p.guiding_poll_sec, p.guiding_timeout_sec, + p.tcs_set_units, p.dra_use_cosdec, p.tcs_sign, p.dry_run); + if (p.adaptive) { + acq_logf( " adaptive=1 faint-start=%.1f faint-goal=%.1f bright-start=%.1f bright-goal=%.1f\n", + p.adaptive_faint, p.adaptive_faint_goal, p.adaptive_bright, p.adaptive_bright_goal); + } + } + + if (p.loop) { + return run_loop(&p); + } + + // One-shot: acquire one frame and (optionally) move once. + if (p.tcs_set_units) (void)tcs_set_native_units(p.dry_run, p.verbose); + FrameState fs; + memset(&fs, 0, sizeof(fs)); + fs.t_accept.tv_sec = 0; + fs.t_accept.tv_nsec = 0; + return process_once(&p, &fs); +} + +int ngps_acq_run_from_argv(int argc, char** argv) { + return ngps_acq_run_internal(argc, argv, 0); +} + +#ifdef __cplusplus +} +#endif + +#ifndef NGPS_ACQ_EMBEDDED +int main(int argc, char** argv) +{ + return ngps_acq_run_internal(argc, argv, 1); +} +#endif diff --git a/slicecamd/slicecam_interface.cpp b/slicecamd/slicecam_interface.cpp index a35022e5..d4a3e4c3 100644 --- a/slicecamd/slicecam_interface.cpp +++ b/slicecamd/slicecam_interface.cpp @@ -9,6 +9,209 @@ */ #include "slicecam_interface.h" +#include "ngps_acq_embed.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +struct AutoacqRunContext { + Slicecam::Interface *iface; + std::string logfile; + std::mutex log_mtx; +}; + +std::string trim_copy( std::string value ) { + value.erase( value.begin(), + std::find_if( value.begin(), value.end(), + [](unsigned char c){ return !std::isspace(c); } ) ); + rtrim( value ); + return value; +} + +bool split_shell_words( const std::string &input, std::vector &tokens, std::string &error ) { + tokens.clear(); + error.clear(); + + std::string current; + char quote = '\0'; + bool escape = false; + + for ( const char ch : input ) { + if ( escape ) { + current.push_back( ch ); + escape = false; + continue; + } + + if ( ch == '\\' ) { + escape = true; + continue; + } + + if ( quote != '\0' ) { + if ( ch == quote ) quote = '\0'; + else current.push_back( ch ); + continue; + } + + if ( ch == '\'' || ch == '"' ) { + quote = ch; + continue; + } + + if ( std::isspace( static_cast(ch) ) ) { + if ( !current.empty() ) { + tokens.push_back( current ); + current.clear(); + } + continue; + } + + current.push_back( ch ); + } + + if ( escape ) { + error = "unterminated escape in autoacq args"; + return false; + } + + if ( quote != '\0' ) { + error = "unterminated quoted string in autoacq args"; + return false; + } + + if ( !current.empty() ) tokens.push_back( current ); + return true; +} + +bool is_autoacq_program_token( const std::string &token ) { + auto slash = token.find_last_of( "/\\" ); + std::string base = ( slash == std::string::npos ? token : token.substr( slash + 1 ) ); + return ( base == "ngps_acq" || base == "ngps_acquire" ); +} + +int cb_tcs_set_native_units( void *user, int dry_run, int verbose ) { + auto *ctx = static_cast( user ); + if ( !ctx || !ctx->iface ) return 1; + + if ( dry_run ) return 0; + + std::string retstring; + if ( !ctx->iface->tcsd.client.is_open() && ctx->iface->tcs_init( "", retstring ) != NO_ERROR ) { + if ( verbose ) logwrite( "Slicecam::autoacq", "WARNING: unable to initialize tcsd client for autoacq" ); + return 1; + } + + if ( ctx->iface->tcsd.client.command( TCSD_NATIVE + " dra arcsec", retstring ) != NO_ERROR ) return 1; + if ( ctx->iface->tcsd.client.command( TCSD_NATIVE + " ddec arcsec", retstring ) != NO_ERROR ) return 1; + return 0; +} + +int cb_tcs_move_arcsec( void *user, double dra_arcsec, double ddec_arcsec, int dry_run, int verbose ) { + auto *ctx = static_cast( user ); + if ( !ctx || !ctx->iface ) return 1; + + if ( dry_run ) return 0; + + std::string retstring; + if ( !ctx->iface->tcsd.client.is_open() && ctx->iface->tcs_init( "", retstring ) != NO_ERROR ) { + if ( verbose ) logwrite( "Slicecam::autoacq", "WARNING: unable to initialize tcsd client for autoacq move" ); + return 1; + } + + std::ostringstream cmd; + cmd << TCSD_NATIVE << " pt " + << std::fixed << std::setprecision(3) << dra_arcsec << " " + << std::fixed << std::setprecision(3) << ddec_arcsec; + return ( ctx->iface->tcsd.client.command( cmd.str(), retstring ) == NO_ERROR ) ? 0 : 1; +} + +int cb_scam_putonslit_deg( void *user, + double slit_ra_deg, double slit_dec_deg, + double cross_ra_deg, double cross_dec_deg, + int dry_run, int /*verbose*/ ) { + auto *ctx = static_cast( user ); + if ( !ctx || !ctx->iface ) return 1; + + if ( dry_run ) return 0; + + std::ostringstream args; + args << std::fixed << std::setprecision(10) + << slit_ra_deg << " " << slit_dec_deg << " " + << cross_ra_deg << " " << cross_dec_deg; + + std::string retstring; + return ( ctx->iface->put_on_slit( args.str(), retstring ) == NO_ERROR ) ? 0 : 1; +} + +int cb_acam_query_state( void *user, char *state, size_t state_sz, int /*verbose*/ ) { + auto *ctx = static_cast( user ); + if ( !ctx || !ctx->iface || !state || state_sz == 0 ) return 2; + + bool is_guiding = false; + if ( ctx->iface->get_acam_guide_state( is_guiding ) != NO_ERROR ) return 1; + + snprintf( state, state_sz, "%s", is_guiding ? "guiding" : "acquiring" ); + return 0; +} + +int cb_scam_framegrab_one( void *user, const char *outpath, int /*verbose*/ ) { + auto *ctx = static_cast( user ); + if ( !ctx || !ctx->iface || !outpath || !*outpath ) return 1; + + std::string retstring; + std::string args = std::string("one ") + outpath; + return ( ctx->iface->framegrab( args, retstring ) == NO_ERROR ) ? 0 : 1; +} + +int cb_scam_set_exptime( void *user, double exptime_sec, int dry_run, int /*verbose*/ ) { + auto *ctx = static_cast( user ); + if ( !ctx || !ctx->iface ) return 1; + if ( dry_run ) return 0; + + std::ostringstream args; + args << std::fixed << std::setprecision(3) << exptime_sec; + + std::string retstring; + return ( ctx->iface->exptime( args.str(), retstring ) == NO_ERROR ) ? 0 : 1; +} + +int cb_scam_set_avgframes( void *user, int avgframes, int dry_run, int /*verbose*/ ) { + auto *ctx = static_cast( user ); + if ( !ctx || !ctx->iface ) return 1; + if ( dry_run ) return 0; + + if ( avgframes < 1 ) avgframes = 1; + std::string retstring; + return ( ctx->iface->avg_frames( std::to_string(avgframes), retstring ) == NO_ERROR ) ? 0 : 1; +} + +int cb_is_stop_requested( void *user ) { + auto *ctx = static_cast( user ); + if ( !ctx || !ctx->iface ) return 1; + return ctx->iface->autoacq_stop_requested() ? 1 : 0; +} + +void cb_log_message( void *user, const char *line ) { + auto *ctx = static_cast( user ); + if ( !ctx || !line || !*line ) return; + + if ( !ctx->logfile.empty() ) { + std::lock_guard lock( ctx->log_mtx ); + std::ofstream logfile( ctx->logfile, std::ios::app ); + if ( logfile.is_open() ) logfile << line; + } +} + +} // namespace namespace Slicecam { @@ -1364,6 +1567,16 @@ namespace Slicecam { applied++; } + if ( config.param[entry] == "AUTOACQ_ARGS" || config.param[entry] == "AUTOACQ_CMD" ) { + this->autoacq_args = trim_copy( config.arg[entry] ); + message.str(""); message << "SLICECAMD:config:" << config.param[entry] << "=" << this->autoacq_args; + logwrite( function, message.str() ); + if ( config.param[entry] == "AUTOACQ_CMD" ) { + logwrite( function, "NOTICE AUTOACQ_CMD is deprecated; use AUTOACQ_ARGS" ); + } + applied++; + } + if ( config.param[entry] == "SKYSIM_IMAGE_SIZE" ) { try { this->camera.set_simsize( std::stoi( config.arg[entry] ) ); @@ -2265,6 +2478,330 @@ namespace Slicecam { /***** Slicecam::Interface::dothread_fpoffset *******************************/ + /***** Slicecam::Interface::publish_autoacq_state ***************************/ + /** + * @brief publish auto-acquire state changes for sequencer + * + */ + void Interface::publish_autoacq_state( const std::string &state, uint64_t run_id, + int exit_code, const std::string &message ) { + if ( !this->publisher ) return; + + nlohmann::json jmessage_out; + jmessage_out["source"] = Slicecam::DAEMON_NAME; + jmessage_out["state"] = state; + jmessage_out["run_id"] = run_id; + jmessage_out["active"] = this->autoacq_running.load(std::memory_order_acquire); + jmessage_out["exit_code"] = exit_code; + jmessage_out["message"] = message; + + try { + this->publisher->publish( jmessage_out, "slicecam_autoacq" ); + } + catch ( const std::exception &e ) { + logwrite( "Slicecam::Interface::publish_autoacq_state", + "ERROR publishing message: "+std::string(e.what()) ); + } + } + /***** Slicecam::Interface::publish_autoacq_state ***************************/ + + + /***** Slicecam::Interface::dothread_autoacq ********************************/ + /** + * @brief run embedded NGPS auto-acquire logic in a worker thread + * + */ + void Interface::dothread_autoacq( std::string args, std::string logfile, uint64_t run_id ) { + const std::string function = "Slicecam::Interface::dothread_autoacq"; + + std::vector tokenized_args; + std::string split_error; + if ( !split_shell_words( trim_copy(args), tokenized_args, split_error ) ) { + const std::string final_message = "invalid autoacq args: " + split_error; + { + std::lock_guard lock( this->autoacq_state_mtx ); + this->autoacq_state = "failed"; + this->autoacq_message = final_message; + this->autoacq_exit_code = 4; + } + this->autoacq_running.store( false, std::memory_order_release ); + this->autoacq_abort_requested.store( false, std::memory_order_release ); + logwrite( function, final_message ); + this->publish_autoacq_state( "failed", run_id, 4, final_message ); + return; + } + + while ( !tokenized_args.empty() && is_autoacq_program_token( tokenized_args.front() ) ) { + tokenized_args.erase( tokenized_args.begin() ); + } + + if ( tokenized_args.empty() ) { + std::vector default_tokens; + std::string default_error; + if ( !split_shell_words( trim_copy(this->autoacq_args), default_tokens, default_error ) ) { + const std::string final_message = "invalid configured AUTOACQ_ARGS: " + default_error; + { + std::lock_guard lock( this->autoacq_state_mtx ); + this->autoacq_state = "failed"; + this->autoacq_message = final_message; + this->autoacq_exit_code = 4; + } + this->autoacq_running.store( false, std::memory_order_release ); + this->autoacq_abort_requested.store( false, std::memory_order_release ); + logwrite( function, final_message ); + this->publish_autoacq_state( "failed", run_id, 4, final_message ); + return; + } + + while ( !default_tokens.empty() && is_autoacq_program_token( default_tokens.front() ) ) { + default_tokens.erase( default_tokens.begin() ); + } + tokenized_args = std::move( default_tokens ); + } + + if ( tokenized_args.empty() ) { + const std::string final_message = "AUTOACQ_ARGS is empty"; + { + std::lock_guard lock( this->autoacq_state_mtx ); + this->autoacq_state = "failed"; + this->autoacq_message = final_message; + this->autoacq_exit_code = 4; + } + this->autoacq_running.store( false, std::memory_order_release ); + this->autoacq_abort_requested.store( false, std::memory_order_release ); + logwrite( function, final_message ); + this->publish_autoacq_state( "failed", run_id, 4, final_message ); + return; + } + + std::vector argv_storage; + argv_storage.reserve( tokenized_args.size() + 1 ); + argv_storage.emplace_back( "ngps_acq" ); + for ( const auto &token : tokenized_args ) argv_storage.push_back( token ); + + std::vector argv; + argv.reserve( argv_storage.size() ); + for ( auto &token : argv_storage ) argv.push_back( const_cast( token.c_str() ) ); + + AutoacqRunContext ctx; + ctx.iface = this; + ctx.logfile = logfile; + + if ( !ctx.logfile.empty() ) { + try { + auto parent = std::filesystem::path( ctx.logfile ).parent_path(); + if ( !parent.empty() ) std::filesystem::create_directories( parent ); + } + catch ( const std::exception &e ) { + logwrite( function, "WARNING: unable to create autoacq log path: " + std::string(e.what()) ); + } + std::ofstream out( ctx.logfile, std::ios::app ); + if ( out.is_open() ) { + out << "=== SLICECAMD AUTOACQ run_id=" << run_id << " start ===" << std::endl; + } + } + + ngps_acq_hooks_t hooks {}; + hooks.user = &ctx; + hooks.tcs_set_native_units = cb_tcs_set_native_units; + hooks.tcs_move_arcsec = cb_tcs_move_arcsec; + hooks.scam_putonslit_deg = cb_scam_putonslit_deg; + hooks.acam_query_state = cb_acam_query_state; + hooks.scam_framegrab_one = cb_scam_framegrab_one; + hooks.scam_set_exptime = cb_scam_set_exptime; + hooks.scam_set_avgframes = cb_scam_set_avgframes; + hooks.is_stop_requested = cb_is_stop_requested; + hooks.log_message = cb_log_message; + + ngps_acq_set_hooks( &hooks ); + ngps_acq_request_stop( 0 ); + const int exit_code = ngps_acq_run_from_argv( static_cast(argv.size()), argv.data() ); + ngps_acq_request_stop( 0 ); + ngps_acq_clear_hooks(); + + std::string final_state; + std::string final_message; + if ( this->autoacq_abort_requested.load(std::memory_order_acquire) ) { + final_state = "aborted"; + final_message = "autoacq aborted"; + } + else if ( exit_code == 0 ) { + final_state = "success"; + final_message = "autoacq complete"; + } + else { + final_state = "failed"; + final_message = "autoacq failed with exit code " + std::to_string(exit_code); + } + + { + std::lock_guard lock( this->autoacq_state_mtx ); + this->autoacq_state = final_state; + this->autoacq_message = final_message; + this->autoacq_exit_code = exit_code; + } + + this->autoacq_running.store( false, std::memory_order_release ); + this->autoacq_abort_requested.store( false, std::memory_order_release ); + + if ( !ctx.logfile.empty() ) { + std::ofstream out( ctx.logfile, std::ios::app ); + if ( out.is_open() ) { + out << "=== SLICECAMD AUTOACQ run_id=" << run_id + << " state=" << final_state + << " exit_code=" << exit_code + << " ===" << std::endl; + } + } + + logwrite( function, final_message ); + this->publish_autoacq_state( final_state, run_id, exit_code, final_message ); + } + /***** Slicecam::Interface::dothread_autoacq ********************************/ + + + /***** Slicecam::Interface::autoacq *****************************************/ + /** + * @brief controls in-process auto-acquire logic + * + */ + long Interface::autoacq( std::string args, std::string &retstring ) { + const std::string function = "Slicecam::Interface::autoacq"; + std::stringstream message; + std::vector tokens; + Tokenize( args, tokens, " " ); + + std::string action = tokens.empty() ? "status" : tokens.at(0); + + if ( action == "?" || action == "help" ) { + retstring = SLICECAMD_AUTOACQ; + retstring.append( " [ start [--log-file ] [] | stop | status ]\n" ); + retstring.append( " Run or monitor in-process auto-acquire logic.\n" ); + retstring.append( " override AUTOACQ_ARGS for this run.\n" ); + retstring.append( " Status returns state, run id, active state, and last exit code.\n" ); + return HELP; + } + + if ( action == "status" ) { + std::string state; + std::string state_message; + int exit_code; + { + std::lock_guard lock( this->autoacq_state_mtx ); + state = this->autoacq_state; + state_message = this->autoacq_message; + exit_code = this->autoacq_exit_code; + } + retstring = "state="+state + + " run_id=" + std::to_string(this->autoacq_run_counter.load(std::memory_order_acquire)) + + " active=" + std::string(this->autoacq_running.load(std::memory_order_acquire) ? "true" : "false") + + " exit_code=" + std::to_string(exit_code); + if ( !state_message.empty() ) retstring += " message=\"" + state_message + "\""; + return NO_ERROR; + } + + if ( action == "stop" ) { + if ( !this->autoacq_running.load(std::memory_order_acquire) ) { + retstring = "not_running"; + return NO_ERROR; + } + + this->autoacq_abort_requested.store( true, std::memory_order_release ); + ngps_acq_request_stop( 1 ); + retstring = "stopping run_id=" + std::to_string(this->autoacq_run_counter.load(std::memory_order_acquire)); + return NO_ERROR; + } + + if ( action == "start" ) { + if ( this->autoacq_running.load(std::memory_order_acquire) ) { + retstring = "autoacq already running"; + return BUSY; + } + + std::string effective_args = trim_copy( this->autoacq_args ); + std::string logfile; + + auto pos = args.find_first_of( " \t" ); + if ( pos != std::string::npos ) { + std::string trailing = trim_copy( args.substr( pos + 1 ) ); + if ( !trailing.empty() ) { + std::vector trailing_tokens; + std::string split_error; + if ( !split_shell_words( trailing, trailing_tokens, split_error ) ) { + retstring = "invalid autoacq args: " + split_error; + return ERROR; + } + + std::vector filtered_tokens; + for ( size_t i = 0; i < trailing_tokens.size(); ) { + if ( trailing_tokens.at(i) == "--log-file" ) { + if ( (i+1) >= trailing_tokens.size() ) { + retstring = "missing value for --log-file"; + return ERROR; + } + logfile = trailing_tokens.at(i+1); + i += 2; + continue; + } + filtered_tokens.push_back( trailing_tokens.at(i) ); + i++; + } + + if ( !filtered_tokens.empty() ) { + std::ostringstream rebuilt; + for ( size_t i = 0; i < filtered_tokens.size(); i++ ) { + if ( i > 0 ) rebuilt << " "; + rebuilt << filtered_tokens.at(i); + } + effective_args = rebuilt.str(); + } + } + } + + std::vector check_tokens; + std::string split_error; + if ( !split_shell_words( effective_args, check_tokens, split_error ) ) { + retstring = "invalid AUTOACQ_ARGS: " + split_error; + return ERROR; + } + if ( check_tokens.size() == 1 && is_autoacq_program_token( check_tokens.front() ) ) { + effective_args = trim_copy( this->autoacq_args ); + } + + if ( trim_copy(effective_args).empty() ) { + logwrite( function, "ERROR AUTOACQ_ARGS is empty" ); + retstring = "AUTOACQ_ARGS is empty"; + return ERROR; + } + + uint64_t run_id = this->autoacq_run_counter.fetch_add( 1, std::memory_order_acq_rel ) + 1; + { + std::lock_guard lock( this->autoacq_state_mtx ); + this->autoacq_state = "running"; + this->autoacq_message = "autoacq started"; + this->autoacq_exit_code = 0; + } + this->autoacq_abort_requested.store( false, std::memory_order_release ); + this->autoacq_running.store( true, std::memory_order_release ); + ngps_acq_request_stop( 0 ); + + message.str(""); message << "NOTICE: starting autoacq run_id=" << run_id << " args=\"" << effective_args << "\""; + if ( !logfile.empty() ) message << " log=" << logfile; + logwrite( function, message.str() ); + + this->publish_autoacq_state( "running", run_id, 0, "autoacq started" ); + std::thread( &Slicecam::Interface::dothread_autoacq, this, effective_args, logfile, run_id ).detach(); + + retstring = "started run_id=" + std::to_string(run_id); + return NO_ERROR; + } + + retstring = "invalid_argument"; + return ERROR; + } + /***** Slicecam::Interface::autoacq *****************************************/ + + /***** Slicecam::Interface::get_acam_guide_state ****************************/ /** * @brief asks if ACAM is guiding @@ -2414,8 +2951,18 @@ namespace Slicecam { return ERROR; } } - else - if ( !is_guiding && this->tcs_online.load(std::memory_order_acquire) && this->tcsd.client.is_open() ) { + else if ( !is_guiding ) { + // Ensure tcsd/tcs connection is available before issuing PT. + // + if ( !this->tcs_online.load(std::memory_order_acquire) || !this->tcsd.client.is_open() ) { + std::string tcsret; + if ( this->tcs_init( "", tcsret ) != NO_ERROR || !this->tcsd.client.is_open() ) { + logwrite( function, "ERROR not connected to tcsd" ); + retstring="tcs_not_connected"; + return ERROR; + } + } + // offsets are in degrees, convert to arcsec (required for PT command) // ra_off *= 3600.; @@ -2429,11 +2976,6 @@ namespace Slicecam { return ERROR; } } - else if ( !is_guiding ) { - logwrite( function, "ERROR not connected to tcsd" ); - retstring="tcs_not_connected"; - return ERROR; - } message.str(""); message << "requested offsets dRA=" << ra_off << " dDEC=" << dec_off << " arcsec"; logwrite( function, message.str() ); diff --git a/slicecamd/slicecam_interface.h b/slicecamd/slicecam_interface.h index 68fd95be..3a443392 100644 --- a/slicecamd/slicecam_interface.h +++ b/slicecamd/slicecam_interface.h @@ -10,6 +10,8 @@ #include #include +#include +#include #include #include "network.h" #include "logentry.h" @@ -18,6 +20,7 @@ #include "atmcdLXd.h" #include #include +#include #include "slicecam_fits.h" #include "config.h" #include "tcsd_commands.h" @@ -239,6 +242,13 @@ namespace Slicecam { std::chrono::steady_clock::time_point framegrab_time; std::mutex framegrab_mtx; std::condition_variable cv; + std::atomic autoacq_running; + std::atomic autoacq_abort_requested; + std::atomic autoacq_run_counter; + std::mutex autoacq_state_mtx; + std::string autoacq_state; + std::string autoacq_message; + int autoacq_exit_code; public: std::unique_ptr publisher; ///< publisher object @@ -287,6 +297,12 @@ namespace Slicecam { : context(), tcs_online(false), err(false), + autoacq_running(false), + autoacq_abort_requested(false), + autoacq_run_counter(0), + autoacq_state("idle"), + autoacq_message(""), + autoacq_exit_code(0), subscriber(std::make_unique(context, Common::PubSub::Mode::SUB)), is_subscriber_thread_running(false), should_subscriber_thread_run(false), @@ -294,7 +310,8 @@ namespace Slicecam { is_framegrab_running(false), nsave_preserve_frames(0), nskip_preserve_frames(0), - snapshot_status { { "slitd", false }, {"tcsd", false} } + snapshot_status { { "slitd", false }, {"tcsd", false} }, + autoacq_args("--input /tmp/slicecam.fits --frame-mode stream --framegrab-out /tmp/ngps_acq.fits --goal-x 151.25 --goal-y 115.25 --pixel-origin 1 --max-dist 40 --snr 10 --min-adj 4 --filt-sigma 1.2 --centroid-hw 6 --centroid-sigma 2.0 --extname L --extnum 1 --bg-x1 80 --bg-x2 200 --bg-y1 30 --bg-y2 210 --search-x1 80 --search-x2 200 --search-y1 30 --search-y2 210 --loop 1 --cadence-sec 2 --max-samples 10 --min-samples 3 --prec-arcsec 0.15 --goal-arcsec 0.15 --max-cycles 20 --gain 1.0 --adaptive 0 --adaptive-faint 500 --adaptive-faint-goal 500 --adaptive-bright 10000 --adaptive-bright-goal 10000 --reject-identical 1 --reject-after-move 3 --settle-sec 0.0 --max-move-arcsec 10 --continue-on-fail 0 --tcs-set-units 0 --use-putonslit 1 --dra-use-cosdec 1 --tcs-sign +1 --wait-guiding 1 --guiding-poll-sec 1.0 --guiding-timeout-sec 120 --debug 1 --debug-out ./ngps_acq_debug.ppm --dry-run 0 --verbose 1") { topic_handlers = { { "_snapshot", std::function( @@ -329,6 +346,7 @@ namespace Slicecam { Common::DaemonClient acamd { "acamd" }; /// for communicating with acamd SkyInfo::FPOffsets fpoffsets; /// for calling Python fpoffsets, defined in ~/Software/common/skyinfo.h + std::string autoacq_args; /// default arguments for in-process auto-acquire // publish/subscribe functions // @@ -359,6 +377,7 @@ namespace Slicecam { long framegrab( std::string args ); /// wrapper to control Andor frame grabbing long framegrab( std::string args, std::string &retstring ); /// wrapper to control Andor frame grabbing long framegrab_fix( std::string args, std::string &retstring ); /// wrapper to control Andor frame grabbing + long autoacq( std::string args, std::string &retstring ); /// run/monitor fine-acquire helper long image_quality( std::string args, std::string &retstring ); /// wrapper for Astrometry::image_quality long put_on_slit( std::string args, std::string &retstring ); /// put target on slit long solve( std::string args, std::string &retstring ); /// wrapper for Astrometry::solve @@ -371,6 +390,9 @@ namespace Slicecam { long gain( std::string args, std::string &retstring ); long get_acam_guide_state( bool &is_guiding ); + bool autoacq_stop_requested() const { + return this->autoacq_abort_requested.load(std::memory_order_acquire); + } long collect_header_info( std::unique_ptr &slicecam ); @@ -380,6 +402,9 @@ namespace Slicecam { static void dothread_fpoffset( Slicecam::Interface &iface ); void dothread_framegrab( const std::string whattodo, const std::string sourcefile ); + void dothread_autoacq( std::string args, std::string logfile, uint64_t run_id ); + void publish_autoacq_state( const std::string &state, uint64_t run_id, + int exit_code, const std::string &message ); void preserve_framegrab(); long collect_header_info_threaded(); }; diff --git a/slicecamd/slicecam_server.cpp b/slicecamd/slicecam_server.cpp index 1c8dd2e8..598394ba 100644 --- a/slicecamd/slicecam_server.cpp +++ b/slicecamd/slicecam_server.cpp @@ -585,6 +585,10 @@ namespace Slicecam { ret = this->interface.saveframes( args, retstring ); } else + if ( cmd == SLICECAMD_AUTOACQ ) { + ret = this->interface.autoacq( args, retstring ); + } + else if ( cmd == SLICECAMD_PUTONSLIT ) { ret = this->interface.put_on_slit( args, retstring ); } From 1450b7c9cff43835e9341cdcb85ee0dc1d80dbc7 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Tue, 10 Feb 2026 19:43:38 -0800 Subject: [PATCH 69/74] Remove CLOEXEC on listening socket --- utils/network.cpp | 4 ---- 1 file changed, 4 deletions(-) diff --git a/utils/network.cpp b/utils/network.cpp index 1eee1af3..5b6a2402 100644 --- a/utils/network.cpp +++ b/utils/network.cpp @@ -563,10 +563,6 @@ namespace Network { return(-1); } - // prevent child processes from inheriting the listening socket - // - fcntl(this->listenfd, F_SETFD, FD_CLOEXEC); - // allow re-binding to port while previous connection is in TIME_WAIT // int on=1; From 3eac78c2653986546cb35ff547b2ec362c689e31 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Tue, 10 Feb 2026 19:59:41 -0800 Subject: [PATCH 70/74] Update default AUTOACQ args profile --- Config/slicecamd.cfg.in | 2 +- slicecamd/slicecam_interface.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Config/slicecamd.cfg.in b/Config/slicecamd.cfg.in index aa28e937..ad197d77 100644 --- a/Config/slicecamd.cfg.in +++ b/Config/slicecamd.cfg.in @@ -24,7 +24,7 @@ TCSD_PORT=@TCSD_BLK_PORT@ ACAMD_PORT=@ACAMD_BLK_PORT@ # AUTOACQ_ARGS defines defaults for slicecamd in-process ngps_acq. # This line intentionally includes all supported ngps_acq options. -AUTOACQ_ARGS="--input /tmp/slicecam.fits --frame-mode stream --framegrab-out /tmp/ngps_acq.fits --goal-x 151.25 --goal-y 115.25 --pixel-origin 1 --max-dist 40 --snr 10 --min-adj 4 --filt-sigma 1.2 --centroid-hw 6 --centroid-sigma 2.0 --extname L --extnum 1 --bg-x1 80 --bg-x2 200 --bg-y1 30 --bg-y2 210 --search-x1 80 --search-x2 200 --search-y1 30 --search-y2 210 --loop 1 --cadence-sec 2 --max-samples 10 --min-samples 3 --prec-arcsec 0.15 --goal-arcsec 0.15 --max-cycles 20 --gain 1.0 --adaptive 1 --adaptive-faint 500 --adaptive-faint-goal 500 --adaptive-bright 40000 --adaptive-bright-goal 10000 --reject-identical 1 --reject-after-move 3 --settle-sec 0.0 --max-move-arcsec 10 --continue-on-fail 0 --tcs-set-units 0 --use-putonslit 1 --dra-use-cosdec 1 --tcs-sign +1 --wait-guiding 1 --guiding-poll-sec 1.0 --guiding-timeout-sec 120 --debug 1 --debug-out ./ngps_acq_debug.ppm --dry-run 0 --verbose 1" +AUTOACQ_ARGS="--frame-mode stream --input /tmp/slicecam.fits --goal-x 150.0 --goal-y 115.5 --bg-x1 80 --bg-x2 165 --bg-y1 30 --bg-y2 210 --pixel-origin 1 --max-dist 40 --snr 3 --min-adj 4 --centroid-hw 4 --centroid-sigma 1.2 --loop 1 --cadence-sec 4 --prec-arcsec 0.4 --goal-arcsec 0.3 --gain 1.0 --dry-run 0 --tcs-set-units 0 --verbose 1 --debug 1 --use-putonslit 1 --adaptive 1 --adaptive-bright 40000 --adaptive-bright-goal 10000" # ANDOR=( [emulate] ) # For each slice camera specify: diff --git a/slicecamd/slicecam_interface.h b/slicecamd/slicecam_interface.h index 3a443392..2c6ad29f 100644 --- a/slicecamd/slicecam_interface.h +++ b/slicecamd/slicecam_interface.h @@ -311,7 +311,7 @@ namespace Slicecam { nsave_preserve_frames(0), nskip_preserve_frames(0), snapshot_status { { "slitd", false }, {"tcsd", false} }, - autoacq_args("--input /tmp/slicecam.fits --frame-mode stream --framegrab-out /tmp/ngps_acq.fits --goal-x 151.25 --goal-y 115.25 --pixel-origin 1 --max-dist 40 --snr 10 --min-adj 4 --filt-sigma 1.2 --centroid-hw 6 --centroid-sigma 2.0 --extname L --extnum 1 --bg-x1 80 --bg-x2 200 --bg-y1 30 --bg-y2 210 --search-x1 80 --search-x2 200 --search-y1 30 --search-y2 210 --loop 1 --cadence-sec 2 --max-samples 10 --min-samples 3 --prec-arcsec 0.15 --goal-arcsec 0.15 --max-cycles 20 --gain 1.0 --adaptive 0 --adaptive-faint 500 --adaptive-faint-goal 500 --adaptive-bright 10000 --adaptive-bright-goal 10000 --reject-identical 1 --reject-after-move 3 --settle-sec 0.0 --max-move-arcsec 10 --continue-on-fail 0 --tcs-set-units 0 --use-putonslit 1 --dra-use-cosdec 1 --tcs-sign +1 --wait-guiding 1 --guiding-poll-sec 1.0 --guiding-timeout-sec 120 --debug 1 --debug-out ./ngps_acq_debug.ppm --dry-run 0 --verbose 1") + autoacq_args("--frame-mode stream --input /tmp/slicecam.fits --goal-x 150.0 --goal-y 115.5 --bg-x1 80 --bg-x2 165 --bg-y1 30 --bg-y2 210 --pixel-origin 1 --max-dist 40 --snr 3 --min-adj 4 --centroid-hw 4 --centroid-sigma 1.2 --loop 1 --cadence-sec 4 --prec-arcsec 0.4 --goal-arcsec 0.3 --gain 1.0 --dry-run 0 --tcs-set-units 0 --verbose 1 --debug 1 --use-putonslit 1 --adaptive 1 --adaptive-bright 40000 --adaptive-bright-goal 10000") { topic_handlers = { { "_snapshot", std::function( From 4d8e1f5c244d1484332e02e5e8a183181fa38b66 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Wed, 11 Feb 2026 00:13:57 -0800 Subject: [PATCH 71/74] Sync seq-progress GUI with ZMQ-only version --- run/seq-progress | 2 +- utils/seq_progress_gui.cpp | 304 +++++++++---------------------------- 2 files changed, 76 insertions(+), 230 deletions(-) diff --git a/run/seq-progress b/run/seq-progress index 12f744c8..4c7e1746 100755 --- a/run/seq-progress +++ b/run/seq-progress @@ -7,4 +7,4 @@ SCRIPT_DIR="$(cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd)" export NGPS_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" CONFIG="${NGPS_ROOT}/Config/sequencerd.cfg" -exec "${NGPS_ROOT}/bin/seq_progress_gui" --config "${CONFIG}" --group NONE --msgport 0 --poll-ms 10000 +exec "${NGPS_ROOT}/bin/seq_progress_gui" --config "${CONFIG}" --poll-ms 10000 diff --git a/utils/seq_progress_gui.cpp b/utils/seq_progress_gui.cpp index 2d0e3840..80fc579f 100644 --- a/utils/seq_progress_gui.cpp +++ b/utils/seq_progress_gui.cpp @@ -1,5 +1,5 @@ // Small X11 sequencer progress popup with ontarget/usercontinue controls. -// Listens to sequencerd async multicast and updates a phase progress bar. +// Uses ZMQ telemetry and TCP polling fallback. #define Time X11Time #include @@ -7,13 +7,13 @@ #include #undef Time -#include #include #include #include #include #include +#include #include #include #include @@ -54,9 +54,6 @@ struct Options { std::string config_path; std::string host = "127.0.0.1"; int nbport = 0; - int msgport = 0; - std::string msggroup = "239.1.1.234"; - bool msggroup_set = false; std::string acam_config_path; std::string acam_host; int acam_nbport = 0; @@ -73,12 +70,8 @@ struct SequenceState { bool offset_applicable = false; bool waiting_for_user = false; bool waiting_for_tcsop = false; - bool user_wait_after_failure = false; - bool user_gate_action_polled = false; - std::string user_gate_action = "NONE"; // NONE|ACQUIRE|EXPOSE|OFFSET_EXPOSE bool ontarget = false; bool guiding_on = false; - bool guiding_failed = false; double exposure_progress = 0.0; double exposure_elapsed = 0.0; double exposure_total = 0.0; @@ -105,12 +98,8 @@ struct SequenceState { offset_applicable = false; waiting_for_user = false; waiting_for_tcsop = false; - user_wait_after_failure = false; - user_gate_action_polled = false; - user_gate_action = "NONE"; ontarget = false; guiding_on = false; - guiding_failed = false; exposure_progress = 0.0; exposure_elapsed = 0.0; exposure_total = 0.0; @@ -134,12 +123,8 @@ struct SequenceState { offset_applicable = false; waiting_for_user = false; waiting_for_tcsop = false; - user_wait_after_failure = false; - user_gate_action_polled = false; - user_gate_action = "NONE"; ontarget = false; guiding_on = false; - guiding_failed = false; exposure_progress = 0.0; exposure_elapsed = 0.0; exposure_total = 0.0; @@ -190,16 +175,6 @@ static std::vector split_state_tokens(const std::string &s) { return out; } -static int parse_int_after_colon(const std::string &s) { - auto pos = s.find(':'); - if (pos == std::string::npos) return 0; - try { - return std::stoi(s.substr(pos + 1)); - } catch (...) { - return 0; - } -} - static Options parse_args(int argc, char **argv) { Options opt; if (const char *env_host = std::getenv("NGPS_HOST"); env_host && *env_host) { @@ -213,11 +188,6 @@ static Options parse_args(int argc, char **argv) { opt.host = argv[++i]; } else if (arg == "--nbport" && i + 1 < argc) { opt.nbport = std::stoi(argv[++i]); - } else if (arg == "--msgport" && i + 1 < argc) { - opt.msgport = std::stoi(argv[++i]); - } else if (arg == "--group" && i + 1 < argc) { - opt.msggroup = argv[++i]; - opt.msggroup_set = true; } else if (arg == "--acam-config" && i + 1 < argc) { opt.acam_config_path = argv[++i]; } else if (arg == "--acam-host" && i + 1 < argc) { @@ -233,7 +203,7 @@ static Options parse_args(int argc, char **argv) { } else if (arg == "--poll-ms" && i + 1 < argc) { opt.poll_ms = std::stoi(argv[++i]); } else if (arg == "--help" || arg == "-h") { - std::cout << "Usage: seq_progress_gui [--config ] [--host ] [--nbport ] [--msgport ] [--group ]\n" + std::cout << "Usage: seq_progress_gui [--config ] [--host ] [--nbport ]\n" " [--acam-config ] [--acam-host ] [--acam-nbport ]\n" " [--pub-endpoint ] [--sub-endpoint ] [--poll-ms ]\n"; std::exit(0); @@ -249,10 +219,6 @@ static void load_config(const std::string &path, Options &opt) { for (int i = 0; i < cfg.n_entries; ++i) { if (cfg.param[i] == "NBPORT" && opt.nbport <= 0) { opt.nbport = std::stoi(cfg.arg[i]); - } else if (cfg.param[i] == "MESSAGEPORT" && opt.msgport <= 0) { - opt.msgport = std::stoi(cfg.arg[i]); - } else if (cfg.param[i] == "MESSAGEGROUP" && !opt.msggroup_set) { - opt.msggroup = cfg.arg[i]; } else if (cfg.param[i] == "PUB_ENDPOINT" && !opt.pub_endpoint_set) { opt.pub_endpoint = cfg.arg[i]; } else if (cfg.param[i] == "SUB_ENDPOINT" && !opt.sub_endpoint_set) { @@ -306,7 +272,6 @@ class SeqProgressGui { public: SeqProgressGui(const Options &opt) : options_(opt), - udp_(static_cast(options_.msgport), options_.msggroup), cmd_iface_("sequencer", options_.host, static_cast(options_.nbport)), acam_iface_("acam", options_.acam_host, static_cast(options_.acam_nbport)) {} @@ -335,21 +300,6 @@ class SeqProgressGui { load_font(); compute_layout(); - bool use_udp = options_.sub_endpoint.empty(); - std::cerr << "DEBUG: use_udp=" << use_udp << " msgport=" << options_.msgport - << " msggroup=" << options_.msggroup << "\n"; - if (use_udp && options_.msgport > 0 && !options_.msggroup.empty() && to_upper_copy(options_.msggroup) != "NONE") { - udp_fd_ = udp_.Listener(); - if (udp_fd_ < 0) { - std::cerr << "ERROR starting UDP listener\n"; - return false; - } - std::cerr << "DEBUG: UDP listener started on port " << options_.msgport - << " group " << options_.msggroup << " fd=" << udp_fd_ << "\n"; - } else { - std::cerr << "DEBUG: UDP listener NOT started\n"; - } - init_zmq(); if (options_.nbport > 0) { @@ -367,7 +317,6 @@ class SeqProgressGui { void run() { const int xfd = ConnectionNumber(display_); - const int ufd = udp_fd(); bool running = true; bool need_redraw = true; auto last_blink = std::chrono::steady_clock::now(); @@ -378,10 +327,6 @@ class SeqProgressGui { FD_ZERO(&fds); FD_SET(xfd, &fds); int maxfd = xfd; - if (ufd >= 0) { - FD_SET(ufd, &fds); - maxfd = std::max(maxfd, ufd); - } struct timeval tv; tv.tv_sec = 0; @@ -389,15 +334,6 @@ class SeqProgressGui { int ret = select(maxfd + 1, &fds, nullptr, nullptr, &tv); if (ret > 0) { - if (ufd >= 0 && FD_ISSET(ufd, &fds)) { - std::string msg; - udp_.Receive(msg); - if (msg.find("EXPTIME:") != std::string::npos) { - std::cerr << "DEBUG UDP received: " << msg.substr(0, 80) << "\n"; - } - handle_message(msg); - need_redraw = true; - } if (FD_ISSET(xfd, &fds)) { while (XPending(display_)) { XEvent ev; @@ -447,14 +383,12 @@ class SeqProgressGui { const int kWinH = 220; Options options_; - Network::UdpSocket udp_; Network::Interface cmd_iface_; Network::Interface acam_iface_; zmqpp::context zmq_context_; std::unique_ptr zmq_sub_; std::unique_ptr zmq_pub_; SequenceState state_; - int udp_fd_ = -1; std::chrono::steady_clock::time_point last_zmq_seqstate_; std::chrono::steady_clock::time_point last_zmq_waitstate_; std::chrono::steady_clock::time_point last_zmq_any_; @@ -490,8 +424,6 @@ class SeqProgressGui { XFontStruct *font_ = nullptr; - int udp_fd() const { return udp_fd_; } - void init_zmq() { if (!options_.sub_endpoint.empty()) { try { @@ -716,27 +648,73 @@ class SeqProgressGui { XDrawString(display_, window_, gc_, tx, ty, label, std::strlen(label)); } + enum class UserGateIntent { + CONTINUE, + ACQUIRE, + EXPOSE, + OFFSET_EXPOSE + }; + + bool has_pending_target_offset() const { + return state_.offset_applicable || + std::fabs(state_.offset_ra) > 1.0e-6 || + std::fabs(state_.offset_dec) > 1.0e-6; + } + + bool is_pre_acquire_gate() const { + if (!state_.waiting_for_user) return false; + if (state_.acqmode != 2) return false; + return !state_.phase_active[PHASE_SOLVE] && + !state_.phase_complete[PHASE_SOLVE] && + !state_.phase_active[PHASE_FINE] && + !state_.phase_complete[PHASE_FINE] && + !state_.phase_active[PHASE_OFFSET] && + !state_.phase_complete[PHASE_OFFSET] && + !state_.phase_active[PHASE_EXPOSE] && + state_.current_frame == 0 && + !state_.guiding_on; + } + + UserGateIntent infer_user_gate_intent() const { + if (!state_.waiting_for_user) return UserGateIntent::CONTINUE; + if (is_pre_acquire_gate()) return UserGateIntent::ACQUIRE; + if (has_pending_target_offset()) return UserGateIntent::OFFSET_EXPOSE; + if (state_.acqmode == 2 || state_.acqmode == 3) return UserGateIntent::EXPOSE; + return UserGateIntent::CONTINUE; + } + + const char *continue_label() const { + switch (infer_user_gate_intent()) { + case UserGateIntent::ACQUIRE: return "ACQUIRE"; + case UserGateIntent::EXPOSE: return "EXPOSE"; + case UserGateIntent::OFFSET_EXPOSE:return "OFFSET & EXPOSE"; + default: return "CONTINUE"; + } + } + void draw_user_instruction() { if (!state_.waiting_for_user) return; if (!blink_on_) return; // blink the instruction for visibility char instruction[256]; - if (!state_.user_gate_action_polled || state_.user_gate_action == "NONE") { - snprintf(instruction, sizeof(instruction), - ">>> Click CONTINUE <<<"); - } else if (state_.user_gate_action == "ACQUIRE") { - snprintf(instruction, sizeof(instruction), - ">>> Click ACQUIRE to start acquisition <<<"); - } else if (state_.user_gate_action == "OFFSET_EXPOSE") { - snprintf(instruction, sizeof(instruction), - ">>> Click OFFSET & EXPOSE to apply offset (RA=%.2f\" DEC=%.2f\") then expose <<<", - state_.offset_ra, state_.offset_dec); - } else if (state_.user_gate_action == "EXPOSE") { - snprintf(instruction, sizeof(instruction), - ">>> Click EXPOSE to begin exposure (no target offset) <<<"); - } else { - snprintf(instruction, sizeof(instruction), - ">>> Waiting for sequencer USER action details... <<<"); + switch (infer_user_gate_intent()) { + case UserGateIntent::ACQUIRE: + snprintf(instruction, sizeof(instruction), + ">>> Click ACQUIRE to start acquisition <<<"); + break; + case UserGateIntent::OFFSET_EXPOSE: + snprintf(instruction, sizeof(instruction), + ">>> Click OFFSET & EXPOSE to apply offset (RA=%.2f\" DEC=%.2f\") then expose <<<", + state_.offset_ra, state_.offset_dec); + break; + case UserGateIntent::EXPOSE: + snprintf(instruction, sizeof(instruction), + ">>> Click EXPOSE to begin exposure <<<"); + break; + default: + snprintf(instruction, sizeof(instruction), + ">>> Click CONTINUE <<<"); + break; } XSetForeground(display_, gc_, color_wait_); // red bold @@ -745,17 +723,7 @@ class SeqProgressGui { void draw_buttons() { draw_button(ontarget_btn_, "ONTARGET", state_.waiting_for_tcsop); - const char *continue_label = "CONTINUE"; - if (state_.waiting_for_user && state_.user_gate_action_polled) { - if (state_.user_gate_action == "ACQUIRE") { - continue_label = "ACQUIRE"; - } else if (state_.user_gate_action == "OFFSET_EXPOSE") { - continue_label = "OFFSET & EXPOSE"; - } else if (state_.user_gate_action == "EXPOSE") { - continue_label = "EXPOSE"; - } - } - draw_button(continue_btn_, continue_label, state_.waiting_for_user); + draw_button(continue_btn_, continue_label(), state_.waiting_for_user); } void draw_ontarget_indicator() { @@ -956,21 +924,6 @@ class SeqProgressGui { if (jmessage.contains("acqmode") && jmessage["acqmode"].is_number()) { state_.acqmode = jmessage["acqmode"].get(); } - if (jmessage.contains("user_gate_action") && jmessage["user_gate_action"].is_string()) { - std::string gate = to_upper_copy(jmessage["user_gate_action"].get()); - if (gate != "ACQUIRE" && gate != "EXPOSE" && gate != "OFFSET_EXPOSE" && gate != "NONE") { - gate = "NONE"; - } - if (state_.waiting_for_user) { - if (gate != "NONE") { - state_.user_gate_action = gate; - state_.user_gate_action_polled = true; - } - } else { - state_.user_gate_action = gate; - state_.user_gate_action_polled = false; - } - } if (jmessage.contains("nexp") && jmessage["nexp"].is_number()) { int new_nexp = jmessage["nexp"].get(); if (new_nexp != state_.nexp) { @@ -1054,8 +1007,7 @@ class SeqProgressGui { const bool allow_tcp_poll = !have_zmq || (zmq_quiet && stale_seq); if (options_.poll_ms > 0) { - // During USER wait, keep polling snapshots until gate action arrives. - if (have_zmq && zmq_pub_ && state_.waiting_for_user && !state_.user_gate_action_polled && + if (have_zmq && zmq_pub_ && state_.waiting_for_user && std::chrono::duration_cast(now - last_snapshot_request_).count() >= 1000) { request_snapshot(); updated = true; @@ -1110,7 +1062,10 @@ class SeqProgressGui { return; } - if (cmd_iface_.send_command("wstate") != 0 && !cmd_iface_.isopen()) { + reply.clear(); + if (cmd_iface_.send_command("wstate", reply, 200) == 0 && !reply.empty()) { + handle_waitstate(reply); + } else if (!cmd_iface_.isopen()) { cmd_iface_.reconnect(); return; } @@ -1136,7 +1091,6 @@ class SeqProgressGui { std::string lower = to_lower_copy(reply); if (lower.find("guiding") != std::string::npos) { state_.guiding_on = true; - state_.guiding_failed = false; } else if (lower.find("stopped") != std::string::npos || lower.find("acquir") != std::string::npos) { state_.guiding_on = false; } @@ -1167,7 +1121,6 @@ class SeqProgressGui { } void handle_waitstate(const std::string &waitstate) { - bool was_waiting_for_user = state_.waiting_for_user; state_.waitstate = waitstate; last_waitstate_update_ = std::chrono::steady_clock::now(); auto tokens = split_ws(waitstate); @@ -1180,17 +1133,9 @@ class SeqProgressGui { state_.waiting_for_tcsop = has_tcsop; state_.waiting_for_user = has_user; - if (has_user && !was_waiting_for_user) { - // New USER gate: default to CONTINUE until explicit gate action is polled. - state_.user_gate_action = "NONE"; - state_.user_gate_action_polled = false; + if (has_user) { request_snapshot(); } - if (!has_user) { - // USER gate action applies only while WAITSTATE includes USER. - state_.user_gate_action = "NONE"; - state_.user_gate_action_polled = false; - } if (!has_tcsop && (has_acquire || has_guide || has_expose || has_readout || has_user)) { state_.ontarget = true; @@ -1243,55 +1188,7 @@ class SeqProgressGui { } } else if (starts_with_local(msg, "WAITSTATE:")) { handle_waitstate(trim_copy(msg.substr(10))); - } else if (starts_with_local(msg, "EXPTIME:")) { - // Parse EXPTIME:remaining total percent - auto parts = split_ws(msg.substr(8)); // Skip "EXPTIME:" - std::cerr << "DEBUG EXPTIME parsing, parts.size=" << parts.size() << "\n"; - if (parts.size() >= 3) { - try { - int remaining_ms = std::stoi(parts[0]); - int total_ms = std::stoi(parts[1]); - int percent = std::stoi(parts[2]); - std::cerr << "DEBUG EXPTIME parsed: remaining=" << remaining_ms - << " total=" << total_ms << " percent=" << percent << "\n"; - if (total_ms > 0) { - int elapsed_ms = total_ms - remaining_ms; - state_.exposure_elapsed = elapsed_ms / 1000.0; - state_.exposure_total = total_ms / 1000.0; - // Smooth the percentage (exponential moving average to handle multiple cameras) - double new_progress = std::min(1.0, percent / 100.0); - if (state_.exposure_progress > 0.0) { - state_.exposure_progress = 0.7 * state_.exposure_progress + 0.3 * new_progress; - } else { - state_.exposure_progress = new_progress; - } - std::cerr << "DEBUG exposure_progress now: " << state_.exposure_progress - << " (from percent=" << percent << ")\n"; - set_phase(PHASE_EXPOSE); - } - } catch (const std::exception &e) { - std::cerr << "DEBUG EXPTIME parse exception: " << e.what() << "\n"; - } - } - } else if (starts_with_local(msg, "ELAPSEDTIME")) { - auto parts = split_ws(msg); - if (parts.size() >= 2) { - int elapsed_ms = parse_int_after_colon(parts[0]); - int total_ms = parse_int_after_colon(parts[1]); - if (total_ms > 0) { - state_.exposure_elapsed = elapsed_ms / 1000.0; - state_.exposure_total = total_ms / 1000.0; - state_.exposure_progress = std::min(1.0, state_.exposure_elapsed / state_.exposure_total); - set_phase(PHASE_EXPOSE); - } - } - } - - if (msg.find("NOTICE: waiting for TCS operator") != std::string::npos) { - state_.waiting_for_tcsop = true; - set_phase(PHASE_SLEW); - } - if (starts_with_local(msg, "TARGETSTATE:")) { + } else if (starts_with_local(msg, "TARGETSTATE:")) { std::string upper = to_upper_copy(msg); int obsid = -1; auto pos = upper.find("OBSID:"); @@ -1319,57 +1216,6 @@ class SeqProgressGui { } } } - if (msg.find("NOTICE: received ontarget") != std::string::npos) { - state_.ontarget = true; - state_.phase_complete[PHASE_SLEW] = true; - clear_phase_active(PHASE_SLEW); - state_.waiting_for_tcsop = false; - state_.last_ontarget = std::chrono::steady_clock::now(); - } - if (msg.find("NOTICE: waiting for USER") != std::string::npos) { - // USER gate intent is driven by seq_waitstate + seq_progress.user_gate_action. - // Ignore async NOTICE text so UDP/non-ZMQ paths cannot override ZMQ truth. - // Detect if this is a failure-based user wait - if (msg.find("guiding failed") != std::string::npos || - msg.find("fine tune failed") != std::string::npos) { - state_.user_wait_after_failure = true; - } - } - if (msg.find("NOTICE: received continue") != std::string::npos) { - state_.user_wait_after_failure = false; - } - if (msg.find("NOTICE: waiting for ACAM guiding") != std::string::npos) { - state_.guiding_on = false; - state_.guiding_failed = false; - set_phase(PHASE_SOLVE); - } - if (msg.find("failed to reach guiding state") != std::string::npos || - msg.find("guiding failed") != std::string::npos) { - state_.guiding_failed = true; - state_.guiding_on = false; - } - if (msg.find("NOTICE: running fine tune command") != std::string::npos) { - state_.guiding_on = true; - state_.guiding_failed = false; - set_phase(PHASE_FINE); - } - if (msg.find("NOTICE: fine tune complete") != std::string::npos) { - state_.guiding_on = true; - state_.guiding_failed = false; - state_.phase_complete[PHASE_FINE] = true; - clear_phase_active(PHASE_FINE); - } - if (msg.find("NOTICE: applying target offset") != std::string::npos) { - state_.offset_applicable = true; - set_phase(PHASE_OFFSET); - } - if (msg.find("NOTICE: waiting for offset settle") != std::string::npos) { - set_phase(PHASE_OFFSET); - } - if (msg.find("NOTICE: running fine tune command") != std::string::npos && - state_.phase_complete[PHASE_SOLVE]) { - clear_phase_active(PHASE_SOLVE); - } } }; @@ -1393,8 +1239,8 @@ int main(int argc, char **argv) { std::cerr << "ERROR: NBPORT not set (check sequencerd.cfg)\n"; return 1; } - if (opt.msgport <= 0 && opt.sub_endpoint.empty()) { - std::cerr << "WARNING: MESSAGEPORT not set and SUB_ENDPOINT empty; only polling will be available\n"; + if (opt.sub_endpoint.empty()) { + std::cerr << "WARNING: SUB_ENDPOINT not set; seq-progress will run in polling-only mode\n"; } SeqProgressGui gui(opt); From b81e363833e763431f32bf5733fa99d783749000 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Wed, 11 Feb 2026 02:07:06 -0800 Subject: [PATCH 72/74] Add fine-tune timeout and autoacq faint-target rescue --- sequencerd/sequence.cpp | 42 ++++++++++++++++++- slicecamd/ngps_acq.c | 90 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 125 insertions(+), 7 deletions(-) diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index b6ff9b9a..f86f11c0 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -841,8 +841,12 @@ namespace Sequencer { } bool sent_stop = false; + bool timed_out = false; auto stop_deadline = std::chrono::steady_clock::time_point::min(); auto next_status_poll = std::chrono::steady_clock::now(); + const bool use_timeout = ( this->acquisition_timeout > 0 ); + const auto fine_tune_timeout = std::chrono::duration( this->acquisition_timeout ); + const auto fine_tune_start = std::chrono::steady_clock::now(); while ( true ) { { std::unique_lock lock( this->fine_tune_mtx ); @@ -866,8 +870,14 @@ namespace Sequencer { if ( tokens.at(0) == "success" || tokens.at(0) == "failed" || tokens.at(0) == "aborted" ) { std::lock_guard lock( this->fine_tune_mtx ); this->fine_tune_done = true; - this->fine_tune_success = ( tokens.at(0) == "success" ); - this->fine_tune_message = "fine tune " + tokens.at(0); + if ( timed_out ) { + this->fine_tune_success = false; + this->fine_tune_message = "fine tune timeout"; + } + else { + this->fine_tune_success = ( tokens.at(0) == "success" ); + this->fine_tune_message = "fine tune " + tokens.at(0); + } break; } } @@ -875,6 +885,34 @@ namespace Sequencer { } } + if ( use_timeout && !timed_out && + std::chrono::steady_clock::now() > ( fine_tune_start + fine_tune_timeout ) ) { + timed_out = true; + this->async.enqueue_and_log( function, + "ERROR: fine tune timed out after " + +std::to_string(this->acquisition_timeout) + +" s; stopping slicecamd autoacq" ); + { + std::lock_guard lock( this->fine_tune_mtx ); + this->fine_tune_success = false; + this->fine_tune_message = "fine tune timeout"; + } + if ( !sent_stop ) { + this->slicecamd.command( SLICECAMD_AUTOACQ + " stop" ); + sent_stop = true; + stop_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + } + } + + if ( timed_out && stop_deadline != std::chrono::steady_clock::time_point::min() && + std::chrono::steady_clock::now() > stop_deadline ) { + std::lock_guard lock( this->fine_tune_mtx ); + this->fine_tune_done = true; + this->fine_tune_success = false; + if ( this->fine_tune_message.empty() ) this->fine_tune_message = "fine tune timeout"; + break; + } + if ( this->cancel_flag.load() ) { if ( !sent_stop ) { this->async.enqueue_and_log( function, "NOTICE: abort requested; stopping slicecamd autoacq" ); diff --git a/slicecamd/ngps_acq.c b/slicecamd/ngps_acq.c index 4254a09f..e647d3ba 100644 --- a/slicecamd/ngps_acq.c +++ b/slicecamd/ngps_acq.c @@ -706,7 +706,8 @@ static int move_to_image_hdu_by_extname(fitsfile* fptr, const char* want_extname static int read_fits_image_and_header(const char* path, const AcqParams* p, float** img_out, long* nx_out, long* ny_out, char** header_out, int* nkeys_out, - int* used_hdu_out, char* used_extname_out, size_t used_extname_sz) + int* used_hdu_out, char* used_extname_out, size_t used_extname_sz, + double* exptime_sec_out) { fitsfile* fptr = NULL; int status = 0; @@ -749,6 +750,17 @@ static int read_fits_image_and_header(const char* path, const AcqParams* p, } } + // record EXPTIME (fallback to 1s if unavailable) + double exptime_sec = 1.0; + { + int keystat = 0; + double exptmp = 0.0; + if (!fits_read_key(fptr, TDOUBLE, "EXPTIME", &exptmp, NULL, &keystat) && + isfinite(exptmp) && exptmp > 0.0) { + exptime_sec = exptmp; + } + } + int bitpix = 0, naxis = 0; long naxes[3] = {0,0,0}; if (fits_get_img_param(fptr, 3, &bitpix, &naxis, naxes, &status)) { @@ -797,6 +809,7 @@ static int read_fits_image_and_header(const char* path, const AcqParams* p, if (used_extname_out && used_extname_sz > 0) { snprintf(used_extname_out, used_extname_sz, "%s", used_extname); } + if (exptime_sec_out) *exptime_sec_out = exptime_sec; return 0; } @@ -1699,7 +1712,8 @@ static int wait_for_stream_update(const char* path, FrameState* fs, double settl static int acquire_next_frame(const AcqParams* p, FrameState* fs, double cadence_sec, float** img_out, long* nx_out, long* ny_out, - char** header_out, int* nkeys_out) + char** header_out, int* nkeys_out, + double* exptime_sec_out) { const char* path = NULL; @@ -1718,12 +1732,15 @@ static int acquire_next_frame(const AcqParams* p, FrameState* fs, int used_hdu = 0; char used_extname[64] = {0}; + double exptime_sec = 1.0; int st = read_fits_image_and_header(path, p, img_out, nx_out, ny_out, header_out, nkeys_out, - &used_hdu, used_extname, sizeof(used_extname)); + &used_hdu, used_extname, sizeof(used_extname), + &exptime_sec); if (st) { acq_logf( "ERROR: CFITSIO read failed for %s (status=%d)\n", path, st); return 4; } + if (exptime_sec_out) *exptime_sec_out = exptime_sec; return 0; } @@ -2059,14 +2076,17 @@ static int process_once(const AcqParams* p, FrameState* fs) long nx=0, ny=0; char* header = NULL; int nkeys = 0; + double exptime_sec = 1.0; - int rc = acquire_next_frame(p, fs, p->cadence_sec, &img, &nx, &ny, &header, &nkeys); + int rc = acquire_next_frame(p, fs, p->cadence_sec, &img, &nx, &ny, &header, &nkeys, &exptime_sec); if (rc) { if (img) free(img); if (header) free(header); return 4; } + (void)exptime_sec; + // signature for rejecting identical frames if (p->reject_identical) { long sx1,sx2,sy1,sy2; @@ -2156,6 +2176,9 @@ static int run_loop(const AcqParams* p) AdaptiveRuntime adaptive_rt; memset(&adaptive_rt, 0, sizeof(adaptive_rt)); adaptive_rt.mode = ADAPT_MODE_BASELINE; + double initial_metric_exptime_sec = 1.0; + int have_initial_metric_exptime = 0; + int initial_metric_scaled_once = 0; for (int cycle = 1; cycle <= p->max_cycles && !stop_requested(); cycle++) { if (p->verbose) acq_logf( "\n=== Cycle %d/%d ===\n", cycle, p->max_cycles); @@ -2164,6 +2187,18 @@ static int run_loop(const AcqParams* p) adaptive_build_cycle_config(p, &adaptive_rt, ADAPT_MODE_BASELINE, 0.0, &cycle_cfg); if (p->adaptive) { double metric_cfg = adaptive_rt.have_metric ? adaptive_rt.metric_ewma : 0.0; + if ( !initial_metric_scaled_once && + adaptive_rt.have_metric && + adaptive_rt.mode != ADAPT_MODE_BASELINE && + have_initial_metric_exptime && + initial_metric_exptime_sec > 0.0 ) { + metric_cfg /= initial_metric_exptime_sec; + initial_metric_scaled_once = 1; + if ( p->verbose ) { + acq_logf( "Adaptive one-shot metric scaling: metric/EXPTIME using initial EXPTIME=%.3fs\n", + initial_metric_exptime_sec ); + } + } adaptive_build_cycle_config(p, &adaptive_rt, adaptive_rt.mode, metric_cfg, &cycle_cfg); (void)adaptive_apply_camera(p, &adaptive_rt, &cycle_cfg); if (p->verbose) { @@ -2199,6 +2234,8 @@ static int run_loop(const AcqParams* p) int attempts = 0; int max_attempts = p->max_samples * 10; + int invalid_solution_total = 0; + int faint_rescue_steps = 0; while (n < p->max_samples && attempts < max_attempts && !stop_requested()) { attempts++; @@ -2207,8 +2244,9 @@ static int run_loop(const AcqParams* p) long nx=0, ny=0; char* header = NULL; int nkeys = 0; + double exptime_sec = 1.0; - int rc = acquire_next_frame(p, &fs, runtime_cadence_sec, &img, &nx, &ny, &header, &nkeys); + int rc = acquire_next_frame(p, &fs, runtime_cadence_sec, &img, &nx, &ny, &header, &nkeys, &exptime_sec); if (rc) { if (img) free(img); if (header) free(header); @@ -2255,6 +2293,43 @@ static int run_loop(const AcqParams* p) if (header) free(header); if (!fr.ok) { + invalid_solution_total++; + if (p->adaptive && invalid_solution_total >= (6 * (faint_rescue_steps + 1))) { + double cur_exp = adaptive_rt.have_last_camera ? adaptive_rt.last_exptime_sec : exptime_sec; + int cur_avg = adaptive_rt.have_last_camera ? adaptive_rt.last_avgframes : 1; + if (!isfinite(cur_exp) || cur_exp <= 0.0) cur_exp = 1.0; + if (cur_avg < 1) cur_avg = 1; + if (cur_avg > 5) cur_avg = 5; + + static const double rescue_exptime_ladder[] = { 0.1, 0.5, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 10.0, 15.0 }; + double target_exp = cur_exp; + for (size_t i = 0; i < sizeof(rescue_exptime_ladder)/sizeof(rescue_exptime_ladder[0]); i++) { + if (rescue_exptime_ladder[i] > cur_exp + 1e-6) { + target_exp = rescue_exptime_ladder[i]; + break; + } + } + + if (target_exp > cur_exp + 1e-6) { + if (p->verbose) { + acq_logf( "Adaptive faint rescue: %d invalid frames; exptime %.3fs -> %.3fs\n", + invalid_solution_total, cur_exp, target_exp); + } + (void)scam_set_exptime(target_exp, p->dry_run, p->verbose); + adaptive_rt.have_last_camera = 1; + adaptive_rt.last_exptime_sec = target_exp; + adaptive_rt.last_avgframes = cur_avg; + runtime_cadence_sec = fmax(runtime_cadence_sec, target_exp * (double)cur_avg + 0.20); + } + else { + if (p->verbose) { + acq_logf( "Adaptive faint rescue: %d invalid frames; exptime cannot increase further (cur=%.3fs)\n", + invalid_solution_total, cur_exp); + } + } + + faint_rescue_steps++; + } if (p->verbose) acq_logf( "Reject: no valid solution (SNR/WCS/star).\n"); continue; } @@ -2264,6 +2339,11 @@ static int run_loop(const AcqParams* p) continue; } + if ( !have_initial_metric_exptime && isfinite(exptime_sec) && exptime_sec > 0.0 ) { + initial_metric_exptime_sec = exptime_sec; + have_initial_metric_exptime = 1; + } + metric_samp[n] = fr.det.src_top10_mean; dra_samp[n] = fr.dra_cmd_arcsec; ddec_samp[n] = fr.ddec_cmd_arcsec; From 0730e86bcce8c7a0c2c648cc2ece34a54511aa96 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Wed, 11 Feb 2026 11:47:06 -0800 Subject: [PATCH 73/74] Remove tools/ngps_db_gui from auto-acq branch --- tools/ngps_db_gui/CMakeLists.txt | 42 - tools/ngps_db_gui/README.md | 34 - tools/ngps_db_gui/main.cpp | 7972 ------------------------------ 3 files changed, 8048 deletions(-) delete mode 100644 tools/ngps_db_gui/CMakeLists.txt delete mode 100644 tools/ngps_db_gui/README.md delete mode 100644 tools/ngps_db_gui/main.cpp diff --git a/tools/ngps_db_gui/CMakeLists.txt b/tools/ngps_db_gui/CMakeLists.txt deleted file mode 100644 index e556782a..00000000 --- a/tools/ngps_db_gui/CMakeLists.txt +++ /dev/null @@ -1,42 +0,0 @@ -cmake_minimum_required(VERSION 3.16) -project(ngps_db_gui LANGUAGES CXX) - -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED ON) - -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTORCC ON) -set(CMAKE_AUTOUIC ON) - -find_package(Qt6 COMPONENTS Widgets QUIET) -if (Qt6_FOUND) - set(QT_LIBS Qt6::Widgets) -else() - find_package(Qt5 COMPONENTS Widgets REQUIRED) - set(QT_LIBS Qt5::Widgets) -endif() - -set(MYSQL_DIR "/usr/local/mysql/connector") -find_path(MYSQL_API "mysqlx/xdevapi.h" - PATHS ${MYSQL_DIR}/include - /usr/local/opt/mysql-connector-c++/include - /opt/homebrew/opt/mysql-connector-c++/include) -if (NOT MYSQL_API) - message(FATAL_ERROR "mysqlx/xdevapi.h not found. Install MySQL Connector/C++") -endif() - -find_library(MYSQL_LIB NAMES mysqlcppconn8 mysqlcppconnx mysqlcppconn - PATHS ${MYSQL_DIR}/lib64 - ${MYSQL_DIR}/lib - /usr/local/opt/mysql-connector-c++/lib - /opt/homebrew/opt/mysql-connector-c++/lib) -if (NOT MYSQL_LIB) - message(FATAL_ERROR "MySQL Connector/C++ library not found") -endif() - -add_executable(ngps_db_gui - main.cpp -) - -target_include_directories(ngps_db_gui PRIVATE ${MYSQL_API}) -target_link_libraries(ngps_db_gui PRIVATE ${QT_LIBS} ${MYSQL_LIB}) diff --git a/tools/ngps_db_gui/README.md b/tools/ngps_db_gui/README.md deleted file mode 100644 index 01069b96..00000000 --- a/tools/ngps_db_gui/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# NGPS DB GUI (Qt/C++) - -This tool provides a Qt Widgets GUI for managing NGPS target sets and targets. - -## Features -- Table views populated directly from the database -- Add dialog for new rows; inline edit by double-click -- Manual refresh button to reload from the DB -- Search targets by `NAME` (case-insensitive, partial match) -- Active targets can be reordered (drag/drop or right-click) -- Reordering updates `OBS_ORDER` using `OBSERVATION_ID` to avoid duplicates -- Set View tab tracks the currently selected target set -- Sequencer controls: `seq start` and `seq abort` -- Activate a target set via `seq targetset ` - -## Build -From this directory: - -```bash -cmake -S . -B build -cmake --build build -``` - -## Run -```bash -./build/ngps_db_gui -``` - -## Notes -- The GUI loads DB settings from `Config/sequencerd.cfg` (auto-detected). -- Requires MySQL Connector/C++ (X DevAPI) for direct DB access (e.g. `mysql-connector-c++`). -- The sequencer buttons run the `seq` command. Set `SEQUENCERD_CONFIG` or - `NGPS_ROOT` in your environment if needed. -- To set a nullable field to NULL in the table, clear the cell or type `NULL`. diff --git a/tools/ngps_db_gui/main.cpp b/tools/ngps_db_gui/main.cpp deleted file mode 100644 index 72d798a3..00000000 --- a/tools/ngps_db_gui/main.cpp +++ /dev/null @@ -1,7972 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { -const char kSettingsOrg[] = "NGPS"; -const char kSettingsApp[] = "ngps_db_gui"; - -const double kDefaultWrangeHalfWidthNm = 15.0; // +/- 150 Ã… -const char kDefaultExptime[] = "SET 5"; -const char kDefaultSlitwidth[] = "SET 1"; -const char kDefaultSlitangle[] = "PA"; -const char kDefaultMagsystem[] = "AB"; -const char kDefaultMagfilter[] = "match"; -const char kDefaultChannel[] = "R"; -const char kDefaultPointmode[] = "SLIT"; -const char kDefaultCcdmode[] = "default"; -const char kDefaultNotBefore[] = "1999-12-31T12:34:56"; -const double kDefaultMagnitude = 19.0; -const double kDefaultAirmassMax = 4.0; -const int kDefaultBin = 1; -const double kDefaultOtmSlitwidth = 1.0; -const char kDefaultTargetState[] = "pending"; -const double kGroupCoordTolArcsec = 1.0; -const double kOffsetZeroTolArcsec = 0.1; -} - -struct NormalizationResult { - QStringList changedColumns; - QString message; -}; - -struct TimelineTarget { - QString obsId; - QString name; - QDateTime startUtc; - QDateTime endUtc; - QVector> segments; - QDateTime slewGoUtc; - QVector airmass; - int obsOrder = 0; - QString flag; - int severity = 0; // 0=none,1=warn,2=error - bool observed = false; - double waitSec = 0.0; -}; - -struct TimelineData { - QVector timesUtc; - QDateTime twilightEvening16; - QDateTime twilightEvening12; - QDateTime twilightMorning12; - QDateTime twilightMorning16; - QVector targets; - double airmassLimit = 0.0; - double delaySlewSec = 0.0; - QVector> idleIntervals; -}; - -static QString explainOtmFlagToken(const QString &token) { - const QString t = token.trimmed().toUpper(); - if (t.isEmpty()) return QString(); - if (t == "DAY-0") { - return "DAY-0: Start time was before twilight; scheduler waited until night."; - } - if (t == "DAY-1") { - return "DAY-1: Daylight reached during/after exposure; remaining targets skipped."; - } - if (t == "DAY-0-1") { - return "DAY-0-1: Daylight reached before this target; target and remaining skipped."; - } - if (t == "SKY") { - return "SKY: Sky background model unavailable/invalid; used fallback sky magnitude."; - } - if (t == "EXPT") { - return "EXPT: Exposure time exceeded warning threshold."; - } - QRegularExpression dofRe("^(AIR|ALT|HA)-([01])$"); - const QRegularExpressionMatch m = dofRe.match(t); - if (m.hasMatch()) { - const QString dof = m.captured(1); - const QString phase = m.captured(2); - const QString phaseText = (phase == "0") ? "before exposure" : "after exposure"; - if (dof == "AIR") { - return QString("AIR-%1: Airmass limit violated %2.").arg(phase, phaseText); - } - if (dof == "ALT") { - return QString("ALT-%1: Altitude limit violated %2.").arg(phase, phaseText); - } - if (dof == "HA") { - return QString("HA-%1: Hour angle limit violated %2.").arg(phase, phaseText); - } - } - return QString("Unknown flag: %1").arg(token); -} - -static QString explainOtmFlags(const QString &flagText) { - QStringList tokens = flagText.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); - if (tokens.isEmpty()) return QString(); - QStringList lines; - for (const QString &token : tokens) { - const QString explanation = explainOtmFlagToken(token); - if (!explanation.isEmpty()) lines << "- " + explanation; - } - if (lines.isEmpty()) return QString(); - return lines.join('\n'); -} - -static QString formatNumber(double value, int precision = 8) { - return QLocale::c().toString(value, 'g', precision); -} - -static QString variantToString(const QVariant &value) { - if (!value.isValid() || value.isNull()) return QString(); - const int type = value.userType(); - if (type == QMetaType::Double || type == QMetaType::Float) { - return formatNumber(value.toDouble()); - } - if (type == QMetaType::Int || type == QMetaType::LongLong) { - return QString::number(value.toLongLong()); - } - if (type == QMetaType::UInt || type == QMetaType::ULongLong) { - return QString::number(value.toULongLong()); - } - return value.toString().trimmed(); -} - -static QString trimOrEmpty(const QString &text) { - return text.trimmed(); -} - -static QStringList splitTokens(const QString &text) { - return text.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); -} - -static QString findKeyCaseInsensitive(const QVariantMap &values, const QString &key) { - if (values.contains(key)) return key; - for (auto it = values.begin(); it != values.end(); ++it) { - if (it.key().compare(key, Qt::CaseInsensitive) == 0) { - return it.key(); - } - } - return key; -} - -static QString valueForKeyCaseInsensitive(const QVariantMap &values, const QString &key) { - const QString actual = findKeyCaseInsensitive(values, key); - return values.value(actual).toString(); -} - -static QString valueToStringCaseInsensitive(const QVariantMap &values, const QString &key) { - const QString actual = findKeyCaseInsensitive(values, key); - return variantToString(values.value(actual)); -} - -static bool containsKeyCaseInsensitive(const QVariantMap &values, const QString &key) { - if (values.contains(key)) return true; - for (auto it = values.begin(); it != values.end(); ++it) { - if (it.key().compare(key, Qt::CaseInsensitive) == 0) { - return true; - } - } - return false; -} - -static bool setContainsCaseInsensitive(const QSet &set, const QString &key) { - if (set.contains(key)) return true; - for (const QString &entry : set) { - if (entry.compare(key, Qt::CaseInsensitive) == 0) return true; - } - return false; -} - -static void setRemoveCaseInsensitive(QSet &set, const QString &key) { - if (set.contains(key)) { - set.remove(key); - return; - } - QString removeKey; - for (const QString &entry : set) { - if (entry.compare(key, Qt::CaseInsensitive) == 0) { - removeKey = entry; - break; - } - } - if (!removeKey.isEmpty()) set.remove(removeKey); -} - -static bool parseDouble(const QString &text, double *value) { - bool ok = false; - const double v = QLocale::c().toDouble(text.trimmed(), &ok); - if (ok && value) *value = v; - return ok; -} - -static bool parseInt(const QString &text, int *value) { - bool ok = false; - const int v = text.trimmed().toInt(&ok); - if (ok && value) *value = v; - return ok; -} - -static bool parseSexagesimalAngle(const QString &text, bool isRa, double *degOut) { - const QString trimmed = text.trimmed(); - if (!trimmed.contains(':')) return false; - const QStringList parts = trimmed.split(':'); - if (parts.size() < 2) return false; - bool ok0 = false; - const QString first = parts.at(0).trimmed(); - const bool neg = (!isRa && first.startsWith('-')); - double a = first.toDouble(&ok0); - if (!ok0) return false; - a = std::abs(a); - bool ok1 = false; - double b = parts.at(1).trimmed().toDouble(&ok1); - if (!ok1) return false; - b = std::abs(b); - double c = 0.0; - if (parts.size() > 2) { - bool ok2 = false; - c = parts.at(2).trimmed().toDouble(&ok2); - if (!ok2) c = 0.0; - c = std::abs(c); - } - double value = a + b / 60.0 + c / 3600.0; - if (isRa) { - if (degOut) *degOut = value * 15.0; - } else { - if (degOut) *degOut = neg ? -value : value; - } - return true; -} - -static bool parseAngleDegrees(const QString &text, bool isRa, double *degOut) { - if (parseSexagesimalAngle(text, isRa, degOut)) return true; - double value = 0.0; - if (!parseDouble(text, &value)) return false; - if (isRa) { - if (std::abs(value) <= 24.0) { - value *= 15.0; - } - } - if (degOut) *degOut = value; - return true; -} - -static double offsetArcsecFromValues(const QVariantMap &values, const QStringList &keys, bool *found) { - for (const QString &key : keys) { - const QString actual = findKeyCaseInsensitive(values, key); - if (!values.contains(actual)) continue; - const QString text = values.value(actual).toString().trimmed(); - if (text.isEmpty()) continue; - double val = 0.0; - if (parseDouble(text, &val)) { - if (found) *found = true; - return val; - } - } - if (found) *found = false; - return 0.0; -} - -static bool computeScienceCoordDegreesProjected(const QVariantMap &values, - double *raDegOut, - double *decDegOut); - -static bool computeScienceCoordKey(const QVariantMap &values, QString *keyOut) { - double raDeg = 0.0; - double decDeg = 0.0; - if (!computeScienceCoordDegreesProjected(values, &raDeg, &decDeg)) return false; - - const double tolDeg = kGroupCoordTolArcsec / 3600.0; - if (tolDeg > 0.0) { - raDeg = std::round(raDeg / tolDeg) * tolDeg; - decDeg = std::round(decDeg / tolDeg) * tolDeg; - } - - while (raDeg < 0.0) raDeg += 360.0; - while (raDeg >= 360.0) raDeg -= 360.0; - - if (keyOut) { - const QString key = QString("%1:%2") - .arg(QString::number(raDeg, 'f', 6)) - .arg(QString::number(decDeg, 'f', 6)); - *keyOut = key; - } - return true; -} - -static bool computeScienceCoordDegrees(const QVariantMap &values, double *raDegOut, double *decDegOut) { - const QString raText = valueToStringCaseInsensitive(values, "RA"); - const QString decText = valueToStringCaseInsensitive(values, "DECL"); - if (raText.isEmpty() || decText.isEmpty()) return false; - double raDeg = 0.0; - double decDeg = 0.0; - if (!parseAngleDegrees(raText, true, &raDeg)) return false; - if (!parseAngleDegrees(decText, false, &decDeg)) return false; - - bool hasRa = false; - bool hasDec = false; - const double offsetRa = offsetArcsecFromValues(values, {"OFFSET_RA", "DRA"}, &hasRa); - const double offsetDec = offsetArcsecFromValues(values, {"OFFSET_DEC", "DDEC"}, &hasDec); - raDeg += offsetRa / 3600.0; - decDeg += offsetDec / 3600.0; - - while (raDeg < 0.0) raDeg += 360.0; - while (raDeg >= 360.0) raDeg -= 360.0; - - if (raDegOut) *raDegOut = raDeg; - if (decDegOut) *decDegOut = decDeg; - return true; -} - -// Gnomonic (tangent plane) projection using offsets in arcsec along RA/Dec axes. -static bool computeScienceCoordDegreesProjected(const QVariantMap &values, - double *raDegOut, - double *decDegOut) { - const QString raText = valueToStringCaseInsensitive(values, "RA"); - const QString decText = valueToStringCaseInsensitive(values, "DECL"); - if (raText.isEmpty() || decText.isEmpty()) return false; - double raDeg = 0.0; - double decDeg = 0.0; - if (!parseAngleDegrees(raText, true, &raDeg)) return false; - if (!parseAngleDegrees(decText, false, &decDeg)) return false; - - bool hasRa = false; - bool hasDec = false; - const double offsetRa = offsetArcsecFromValues(values, {"OFFSET_RA", "DRA"}, &hasRa); - const double offsetDec = offsetArcsecFromValues(values, {"OFFSET_DEC", "DDEC"}, &hasDec); - - const double deg2rad = 3.14159265358979323846 / 180.0; - const double ra0 = raDeg * deg2rad; - const double dec0 = decDeg * deg2rad; - const double xi = (offsetRa / 3600.0) * deg2rad; - const double eta = (offsetDec / 3600.0) * deg2rad; - - const double sinDec0 = std::sin(dec0); - const double cosDec0 = std::cos(dec0); - const double denom = cosDec0 - eta * sinDec0; - const double ra1 = ra0 + std::atan2(xi, denom); - const double dec1 = std::atan2(sinDec0 + eta * cosDec0, std::sqrt(denom * denom + xi * xi)); - - double raDegOutLocal = ra1 / deg2rad; - double decDegOutLocal = dec1 / deg2rad; - while (raDegOutLocal < 0.0) raDegOutLocal += 360.0; - while (raDegOutLocal >= 360.0) raDegOutLocal -= 360.0; - - if (raDegOut) *raDegOut = raDegOutLocal; - if (decDegOut) *decDegOut = decDegOutLocal; - return true; -} - -static double angularSeparationArcsec(double raDeg1, double decDeg1, - double raDeg2, double decDeg2) { - const double deg2rad = 3.14159265358979323846 / 180.0; - const double ra1 = raDeg1 * deg2rad; - const double dec1 = decDeg1 * deg2rad; - const double ra2 = raDeg2 * deg2rad; - const double dec2 = decDeg2 * deg2rad; - const double cosd = std::sin(dec1) * std::sin(dec2) + - std::cos(dec1) * std::cos(dec2) * std::cos(ra1 - ra2); - const double clamped = std::max(-1.0, std::min(1.0, cosd)); - const double sepRad = std::acos(clamped); - return sepRad * (180.0 / 3.14159265358979323846) * 3600.0; -} - -static bool channelRangeFor(const QString &channel, double *minNm, double *maxNm) { - const QString ch = channel.trimmed().toUpper(); - if (ch == "U") { if (minNm) *minNm = 310.0; if (maxNm) *maxNm = 436.0; return true; } - if (ch == "G") { if (minNm) *minNm = 417.0; if (maxNm) *maxNm = 590.0; return true; } - if (ch == "R") { if (minNm) *minNm = 561.0; if (maxNm) *maxNm = 794.0; return true; } - if (ch == "I") { if (minNm) *minNm = 756.0; if (maxNm) *maxNm = 1040.0; return true; } - return false; -} - -static QPair defaultWrangeForChannel(const QString &channel) { - double minNm = 0.0; - double maxNm = 0.0; - if (!channelRangeFor(channel, &minNm, &maxNm)) { - channelRangeFor(kDefaultChannel, &minNm, &maxNm); - } - const double center = 0.5 * (minNm + maxNm); - return {center - kDefaultWrangeHalfWidthNm, center + kDefaultWrangeHalfWidthNm}; -} - -static QString normalizeChannelValue(const QString &text) { - const QString ch = text.trimmed().toUpper(); - if (ch == "U" || ch == "G" || ch == "R" || ch == "I") return ch; - return kDefaultChannel; -} - -static QString normalizeMagsystemValue(const QString &text) { - const QString val = text.trimmed().toUpper(); - if (val == "AB" || val == "VEGA") return val; - return kDefaultMagsystem; -} - -static QString normalizeMagfilterValue(const QString &text) { - const QString val = text.trimmed(); - if (val.isEmpty()) return kDefaultMagfilter; - const QString upper = val.toUpper(); - if (upper == "G") return kDefaultMagfilter; - if (upper == "MATCH") return kDefaultMagfilter; - if (upper == "USER") return "user"; - if (upper == "U" || upper == "B" || upper == "V" || upper == "R" || - upper == "I" || upper == "J" || upper == "K") { - return upper; - } - return kDefaultMagfilter; -} - -static QString normalizePointmodeValue(const QString &text) { - const QString val = text.trimmed().toUpper(); - if (val == "SLIT" || val == "ACAM") return val; - return kDefaultPointmode; -} - -static QString normalizeCcdmodeValue(const QString &text) { - const QString val = text.trimmed(); - if (val.compare("default", Qt::CaseInsensitive) == 0) return kDefaultCcdmode; - return kDefaultCcdmode; -} - -static QString normalizeSrcmodelValue(const QString &text) { - QString val = text.trimmed(); - if (val.isEmpty()) return "-model constant"; - if (val.startsWith("-", Qt::CaseInsensitive)) return val; - if (val.startsWith("model", Qt::CaseInsensitive)) return "-" + val; - return QString("-model %1").arg(val); -} - -static QString normalizeSlitangleValue(const QString &text) { - const QString val = text.trimmed(); - if (val.isEmpty()) return kDefaultSlitangle; - if (val.compare("PA", Qt::CaseInsensitive) == 0) return "PA"; - double num = 0.0; - if (parseDouble(val, &num)) return formatNumber(num); - return kDefaultSlitangle; -} - -static QString normalizeExptimeValue(const QString &text, bool isCalib) { - Q_UNUSED(isCalib); - QString val = text.trimmed(); - if (val.isEmpty()) return kDefaultExptime; - QStringList parts = splitTokens(val); - if (parts.size() == 1) { - double num = 0.0; - if (parseDouble(parts[0], &num) && num > 0) { - return QString("SET %1").arg(formatNumber(num)); - } - return kDefaultExptime; - } - QString key = parts[0].toUpper(); - if (key == "EXPTIME") key = "SET"; - if (key == "SET" || key == "SNR") { - double num = 0.0; - if (parts.size() >= 2 && parseDouble(parts[1], &num) && num > 0) { - return QString("%1 %2").arg(key, formatNumber(num)); - } - } - return kDefaultExptime; -} - -static QString normalizeSlitwidthValue(const QString &text, bool isCalib) { - Q_UNUSED(isCalib); - QString val = text.trimmed(); - if (val.isEmpty()) return kDefaultSlitwidth; - QStringList parts = splitTokens(val); - if (parts.size() == 1) { - const QString key = parts[0].toUpper(); - if (key == "AUTO") return "AUTO"; - double num = 0.0; - if (parseDouble(parts[0], &num) && num > 0) { - return QString("SET %1").arg(formatNumber(num)); - } - return kDefaultSlitwidth; - } - QString key = parts[0].toUpper(); - if (key == "AUTO") return "AUTO"; - if (key == "SET" || key == "SNR" || key == "RES" || key == "LOSS") { - double num = 0.0; - if (parts.size() >= 2 && parseDouble(parts[1], &num) && num > 0) { - return QString("%1 %2").arg(key, formatNumber(num)); - } - } - return kDefaultSlitwidth; -} - -static bool extractSetNumeric(const QString &text, double *valueOut) { - QStringList parts = splitTokens(text); - if (parts.size() >= 2 && parts[0].compare("SET", Qt::CaseInsensitive) == 0) { - double num = 0.0; - if (parseDouble(parts[1], &num) && num > 0) { - if (valueOut) *valueOut = num; - return true; - } - } - return false; -} - -static QStringList parseCsvLine(const QString &line) { - QStringList fields; - QString field; - bool inQuotes = false; - for (int i = 0; i < line.size(); ++i) { - const QChar ch = line.at(i); - if (ch == '"') { - if (inQuotes && i + 1 < line.size() && line.at(i + 1) == '"') { - field += '"'; - ++i; - } else { - inQuotes = !inQuotes; - } - } else if (ch == ',' && !inQuotes) { - fields << field.trimmed(); - field.clear(); - } else { - field += ch; - } - } - fields << field.trimmed(); - return fields; -} - -static QString csvEscape(const QString &value) { - if (value.contains(',') || value.contains('"') || value.contains('\n') || value.contains('\r')) { - QString escaped = value; - escaped.replace('"', "\"\""); - return QString("\"%1\"").arg(escaped); - } - return value; -} - -static QString normalizeOtmTimestamp(const QString &value) { - QString out = value.trimmed(); - if (out.isEmpty()) return out; - if (out.compare("None", Qt::CaseInsensitive) == 0) return QString(); - out.replace('T', ' '); - return out; -} - -static QDateTime parseUtcIso(const QString &text) { - QString trimmed = text.trimmed(); - if (trimmed.isEmpty()) return QDateTime(); - QDateTime dt = QDateTime::fromString(trimmed, Qt::ISODateWithMs); - if (!dt.isValid()) { - dt = QDateTime::fromString(trimmed, Qt::ISODate); - } - if (!dt.isValid()) return QDateTime(); - dt.setTimeSpec(Qt::UTC); - return dt; -} - -static int otmFlagSeverity(const QString &flag) { - const QString trimmed = flag.trimmed(); - if (trimmed.isEmpty()) return 0; - const QString upper = trimmed.toUpper(); - if (upper.contains("DAY-0-1") || upper.contains("DAY-1")) return 2; - QRegularExpression errRe("\\b[A-Z]+1\\b"); - if (errRe.match(upper).hasMatch()) return 2; - return 1; -} - -static QString mergeFlagText(const QString &existing, const QString &incoming) { - QStringList ordered; - QSet seen; - auto addTokens = [&](const QString &text) { - const QStringList tokens = text.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); - for (const QString &token : tokens) { - const QString upper = token.trimmed(); - if (upper.isEmpty()) continue; - if (seen.contains(upper)) continue; - seen.insert(upper); - ordered << upper; - } - }; - addTokens(existing); - addTokens(incoming); - return ordered.join(' '); -} - -static bool loadTimelineJson(const QString &path, TimelineData *data, QString *error) { - if (!data) return false; - QFile file(path); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - if (error) *error = "Unable to read timeline data."; - return false; - } - const QByteArray raw = file.readAll(); - QJsonParseError parseError; - QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError); - if (doc.isNull()) { - if (error) *error = parseError.errorString(); - return false; - } - const QJsonObject root = doc.object(); - TimelineData tmp; - const QJsonArray times = root.value("times_utc").toArray(); - tmp.timesUtc.reserve(times.size()); - for (const QJsonValue &val : times) { - QDateTime dt = parseUtcIso(val.toString()); - if (dt.isValid()) tmp.timesUtc.append(dt); - } - - const QJsonObject twilight = root.value("twilight").toObject(); - tmp.twilightEvening16 = parseUtcIso(twilight.value("evening_16").toString()); - tmp.twilightEvening12 = parseUtcIso(twilight.value("evening_12").toString()); - tmp.twilightMorning12 = parseUtcIso(twilight.value("morning_12").toString()); - tmp.twilightMorning16 = parseUtcIso(twilight.value("morning_16").toString()); - tmp.delaySlewSec = root.value("delay_slew_sec").toDouble(0.0); - - const QJsonArray idle = root.value("idle_intervals").toArray(); - tmp.idleIntervals.reserve(idle.size()); - for (const QJsonValue &val : idle) { - const QJsonObject obj = val.toObject(); - const QDateTime s = parseUtcIso(obj.value("start").toString()); - const QDateTime e = parseUtcIso(obj.value("end").toString()); - if (s.isValid() && e.isValid() && e > s) { - tmp.idleIntervals.append({s, e}); - } - } - - const QJsonArray targets = root.value("targets").toArray(); - tmp.targets.reserve(targets.size()); - QHash targetIndexByKey; - for (const QJsonValue &val : targets) { - const QJsonObject obj = val.toObject(); - TimelineTarget target; - target.obsId = obj.value("obs_id").toString(); - target.name = obj.value("name").toString(); - target.startUtc = parseUtcIso(obj.value("start").toString()); - target.endUtc = parseUtcIso(obj.value("end").toString()); - target.slewGoUtc = parseUtcIso(obj.value("slew_go").toString()); - target.flag = obj.value("flag").toString(); - target.observed = obj.value("observed").toBool(); - if (!target.observed) { - target.observed = target.startUtc.isValid() && target.endUtc.isValid(); - } - target.waitSec = obj.value("wait_sec").toDouble(0.0); - target.severity = otmFlagSeverity(target.flag); - const QJsonArray airmass = obj.value("airmass").toArray(); - target.airmass.reserve(airmass.size()); - for (const QJsonValue &av : airmass) { - target.airmass.append(av.toDouble(std::numeric_limits::quiet_NaN())); - } - const QString key = !target.obsId.isEmpty() ? target.obsId : target.name; - if (key.isEmpty()) continue; - if (target.startUtc.isValid() && target.endUtc.isValid()) { - target.segments.append({target.startUtc, target.endUtc}); - } - if (!targetIndexByKey.contains(key)) { - targetIndexByKey.insert(key, tmp.targets.size()); - tmp.targets.append(target); - continue; - } - TimelineTarget &existing = tmp.targets[targetIndexByKey.value(key)]; - if (!target.obsId.isEmpty()) existing.obsId = target.obsId; - if (existing.name.isEmpty()) existing.name = target.name; - if (existing.airmass.isEmpty() && !target.airmass.isEmpty()) { - existing.airmass = target.airmass; - } - if (target.startUtc.isValid() && target.endUtc.isValid()) { - existing.segments.append({target.startUtc, target.endUtc}); - if (!existing.startUtc.isValid() || target.startUtc < existing.startUtc) { - existing.startUtc = target.startUtc; - } - if (!existing.endUtc.isValid() || target.endUtc > existing.endUtc) { - existing.endUtc = target.endUtc; - } - } - if (target.slewGoUtc.isValid() && !existing.slewGoUtc.isValid()) { - existing.slewGoUtc = target.slewGoUtc; - } - existing.waitSec = std::max(existing.waitSec, target.waitSec); - existing.observed = existing.observed || target.observed; - existing.severity = std::max(existing.severity, target.severity); - existing.flag = mergeFlagText(existing.flag, target.flag); - } - - *data = tmp; - return true; -} - -static void setNormalizedValue(QVariantMap &values, - QSet &nullColumns, - const QString &column, - const QString &newValue, - QStringList *changedColumns) { - const QString actualKey = findKeyCaseInsensitive(values, column); - const QString current = values.value(actualKey).toString(); - if (current != newValue) { - if (changedColumns) changedColumns->append(column); - } - values.insert(actualKey, newValue); - setRemoveCaseInsensitive(nullColumns, column); -} - -static NormalizationResult normalizeTargetRow(QVariantMap &values, QSet &nullColumns) { - NormalizationResult result; - - const QString name = values.value("NAME").toString().trimmed(); - const bool isCalib = name.toUpper().startsWith("CAL_"); - - if (containsKeyCaseInsensitive(values, "CHANNEL")) { - setNormalizedValue(values, nullColumns, "CHANNEL", - normalizeChannelValue(valueForKeyCaseInsensitive(values, "CHANNEL")), - &result.changedColumns); - } - - if (containsKeyCaseInsensitive(values, "SLITANGLE")) { - setNormalizedValue(values, nullColumns, "SLITANGLE", - normalizeSlitangleValue(valueForKeyCaseInsensitive(values, "SLITANGLE")), - &result.changedColumns); - } - - if (containsKeyCaseInsensitive(values, "EXPTIME")) { - const QString normalized = normalizeExptimeValue(valueForKeyCaseInsensitive(values, "EXPTIME"), isCalib); - setNormalizedValue(values, nullColumns, "EXPTIME", normalized, &result.changedColumns); - } - - if (containsKeyCaseInsensitive(values, "SLITWIDTH")) { - const QString normalized = normalizeSlitwidthValue(valueForKeyCaseInsensitive(values, "SLITWIDTH"), isCalib); - setNormalizedValue(values, nullColumns, "SLITWIDTH", normalized, &result.changedColumns); - } - - if (containsKeyCaseInsensitive(values, "BINSPECT")) { - int val = 0; - if (!parseInt(valueForKeyCaseInsensitive(values, "BINSPECT"), &val) || val <= 0) { - setNormalizedValue(values, nullColumns, "BINSPECT", QString::number(kDefaultBin), - &result.changedColumns); - } - } - - if (containsKeyCaseInsensitive(values, "NEXP")) { - int val = 0; - if (!parseInt(valueForKeyCaseInsensitive(values, "NEXP"), &val) || val <= 0) { - setNormalizedValue(values, nullColumns, "NEXP", "1", &result.changedColumns); - } - } - - if (containsKeyCaseInsensitive(values, "BINSPAT")) { - int val = 0; - if (!parseInt(valueForKeyCaseInsensitive(values, "BINSPAT"), &val) || val <= 0) { - setNormalizedValue(values, nullColumns, "BINSPAT", QString::number(kDefaultBin), - &result.changedColumns); - } - } - - if (containsKeyCaseInsensitive(values, "AIRMASS_MAX")) { - double val = 0.0; - if (!parseDouble(valueForKeyCaseInsensitive(values, "AIRMASS_MAX"), &val) || val < 1.0) { - setNormalizedValue(values, nullColumns, "AIRMASS_MAX", formatNumber(kDefaultAirmassMax), - &result.changedColumns); - } - } - - if (containsKeyCaseInsensitive(values, "POINTMODE")) { - setNormalizedValue(values, nullColumns, "POINTMODE", - normalizePointmodeValue(valueForKeyCaseInsensitive(values, "POINTMODE")), - &result.changedColumns); - } - - if (containsKeyCaseInsensitive(values, "CCDMODE")) { - setNormalizedValue(values, nullColumns, "CCDMODE", - normalizeCcdmodeValue(valueForKeyCaseInsensitive(values, "CCDMODE")), - &result.changedColumns); - } - - if (containsKeyCaseInsensitive(values, "MAGNITUDE")) { - double val = 0.0; - if (!parseDouble(valueForKeyCaseInsensitive(values, "MAGNITUDE"), &val)) { - setNormalizedValue(values, nullColumns, "MAGNITUDE", formatNumber(kDefaultMagnitude), - &result.changedColumns); - } - } - - if (containsKeyCaseInsensitive(values, "MAGSYSTEM")) { - setNormalizedValue(values, nullColumns, "MAGSYSTEM", - normalizeMagsystemValue(valueForKeyCaseInsensitive(values, "MAGSYSTEM")), - &result.changedColumns); - } - - if (containsKeyCaseInsensitive(values, "MAGFILTER")) { - setNormalizedValue(values, nullColumns, "MAGFILTER", - normalizeMagfilterValue(valueForKeyCaseInsensitive(values, "MAGFILTER")), - &result.changedColumns); - } - - if (containsKeyCaseInsensitive(values, "NOTBEFORE")) { - const QString val = valueForKeyCaseInsensitive(values, "NOTBEFORE").trimmed(); - if (val.isEmpty()) { - setNormalizedValue(values, nullColumns, "NOTBEFORE", kDefaultNotBefore, &result.changedColumns); - } - } - - const bool hasWrangeLow = containsKeyCaseInsensitive(values, "WRANGE_LOW"); - const bool hasWrangeHigh = containsKeyCaseInsensitive(values, "WRANGE_HIGH"); - if (hasWrangeLow || hasWrangeHigh) { - const QString channel = valueForKeyCaseInsensitive(values, "CHANNEL"); - double low = 0.0; - double high = 0.0; - bool lowOk = hasWrangeLow && parseDouble(valueForKeyCaseInsensitive(values, "WRANGE_LOW"), &low); - bool highOk = hasWrangeHigh && parseDouble(valueForKeyCaseInsensitive(values, "WRANGE_HIGH"), &high); - - if (!lowOk || !highOk || high <= low) { - const auto def = defaultWrangeForChannel(channel); - low = def.first; - high = def.second; - } - - if (hasWrangeLow) { - setNormalizedValue(values, nullColumns, "WRANGE_LOW", formatNumber(low), - &result.changedColumns); - } - if (hasWrangeHigh) { - setNormalizedValue(values, nullColumns, "WRANGE_HIGH", formatNumber(high), - &result.changedColumns); - } - } - - if (values.contains("EXPTIME") && isCalib) { - const QString exptime = valueForKeyCaseInsensitive(values, "EXPTIME"); - if (!exptime.toUpper().startsWith("SET")) { - setNormalizedValue(values, nullColumns, "EXPTIME", kDefaultExptime, &result.changedColumns); - } - } - if (containsKeyCaseInsensitive(values, "SLITWIDTH") && isCalib) { - const QString slitwidth = valueForKeyCaseInsensitive(values, "SLITWIDTH"); - if (!slitwidth.toUpper().startsWith("SET")) { - setNormalizedValue(values, nullColumns, "SLITWIDTH", kDefaultSlitwidth, &result.changedColumns); - } - } - - if (containsKeyCaseInsensitive(values, "OTMslitwidth")) { - const QString slitwidth = valueForKeyCaseInsensitive(values, "SLITWIDTH"); - double numeric = 0.0; - bool haveSet = extractSetNumeric(slitwidth, &numeric); - QString current = valueForKeyCaseInsensitive(values, "OTMslitwidth").trimmed(); - if (current.isEmpty()) { - const double toUse = haveSet ? numeric : kDefaultOtmSlitwidth; - setNormalizedValue(values, nullColumns, "OTMslitwidth", formatNumber(toUse), - &result.changedColumns); - } - } - - if (containsKeyCaseInsensitive(values, "OTMexpt")) { - const QString exptime = valueForKeyCaseInsensitive(values, "EXPTIME"); - double numeric = 0.0; - if (extractSetNumeric(exptime, &numeric)) { - setNormalizedValue(values, nullColumns, "OTMexpt", formatNumber(numeric), - &result.changedColumns); - } - } - - return result; -} - -struct ColumnMeta { - QString name; - QString type; - QString key; - QVariant defaultValue; - QString extra; - bool nullable = true; - - bool isPrimaryKey() const { return key.contains("PRI", Qt::CaseInsensitive); } - bool isAutoIncrement() const { return extra.contains("auto_increment", Qt::CaseInsensitive); } - bool hasDefault() const { return !defaultValue.isNull(); } - bool isDateTime() const { - const QString t = type.toLower(); - return t.contains("timestamp") || t.contains("datetime"); - } -}; - -struct DbConfig { - QString host; - int port = 33060; - QString user; - QString pass; - QString schema; - QString tableTargets; - QString tableSets; - - bool isComplete() const { - return !host.isEmpty() && !user.isEmpty() && !schema.isEmpty() && - !tableTargets.isEmpty() && !tableSets.isEmpty(); - } -}; - -static QString stripInlineComment(const QString &line) { - int idx = line.indexOf('#'); - if (idx >= 0) { - return line.left(idx).trimmed(); - } - return line.trimmed(); -} - -static DbConfig loadConfigFile(const QString &path) { - DbConfig cfg; - QFile file(path); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - return cfg; - } - QTextStream in(&file); - while (!in.atEnd()) { - QString line = in.readLine().trimmed(); - if (line.isEmpty() || line.startsWith('#')) { - continue; - } - line = stripInlineComment(line); - int eq = line.indexOf('='); - if (eq <= 0) { - continue; - } - const QString key = line.left(eq).trimmed(); - const QString value = line.mid(eq + 1).trimmed(); - if (key == "DB_HOST") cfg.host = value; - else if (key == "DB_PORT") cfg.port = value.toInt(); - else if (key == "DB_USER") cfg.user = value; - else if (key == "DB_PASS") cfg.pass = value; - else if (key == "DB_SCHEMA") cfg.schema = value; - else if (key == "DB_ACTIVE") cfg.tableTargets = value.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts).value(0); - else if (key == "DB_SETS") cfg.tableSets = value.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts).value(0); - } - return cfg; -} - -static QString detectDefaultConfigPath() { - if (qEnvironmentVariableIsSet("NGPS_CONFIG")) { - return qEnvironmentVariable("NGPS_CONFIG"); - } - if (qEnvironmentVariableIsSet("NGPS_ROOT")) { - const QString root = qEnvironmentVariable("NGPS_ROOT"); - const QString candidate = QDir(root).filePath("Config/sequencerd.cfg"); - if (QFile::exists(candidate)) return candidate; - } - const QString appDir = QCoreApplication::applicationDirPath(); - QDir dir(appDir); - for (int i = 0; i < 6; ++i) { - const QString candidate = dir.filePath("Config/sequencerd.cfg"); - if (QFile::exists(candidate)) return candidate; - if (!dir.cdUp()) break; - } - const QString cwd = QDir::currentPath(); - const QString direct = QDir(cwd).filePath("Config/sequencerd.cfg"); - if (QFile::exists(direct)) return direct; - const QString upOne = QDir(cwd).filePath("../Config/sequencerd.cfg"); - if (QFile::exists(upOne)) return upOne; - return QString(); -} - -static QString inferNgpsRootFromConfig(const QString &configPath) { - QFileInfo info(configPath); - if (!info.exists()) return QString(); - QDir dir = info.dir(); - if (dir.dirName().toLower() == "config") { - dir.cdUp(); - return dir.absolutePath(); - } - return info.absoluteDir().absolutePath(); -} - -static QVariant mysqlValueToVariant(const mysqlx::Value &value) { - using mysqlx::Value; - switch (value.getType()) { - case Value::VNULL: - return QVariant(); - case Value::INT64: - return QVariant::fromValue(value.get()); - case Value::UINT64: - return QVariant::fromValue(value.get()); - case Value::FLOAT: - return QVariant::fromValue(value.get()); - case Value::DOUBLE: - return QVariant::fromValue(value.get()); - case Value::BOOL: - return QVariant::fromValue(value.get()); - case Value::STRING: - return QString::fromStdString(value.get()); - case Value::RAW: { - mysqlx::bytes raw = value.get(); - QByteArray bytes(reinterpret_cast(raw.first), - static_cast(raw.second)); - bool printable = true; - for (unsigned char ch : bytes) { - if (ch == 0) { printable = false; break; } - if (!std::isprint(ch) && !std::isspace(ch)) { printable = false; break; } - } - if (printable) { - return QString::fromUtf8(bytes); - } - return bytes; - } - case Value::ARRAY: { - std::ostringstream stream; - stream << value; - return QString::fromStdString(stream.str()); - } - case Value::DOCUMENT: - return QString::fromStdString(value.get()); - } - return QVariant(); -} - -static QString displayForVariant(const QVariant &value, bool isNull) { - if (isNull) { - return "NULL"; - } - if (value.userType() == QMetaType::QByteArray) { - return value.toByteArray().toHex(' ').toUpper(); - } - return value.toString(); -} - -static bool isLongMessage(const QString &text) { - const int maxChars = 600; - const int maxLines = 20; - return text.size() > maxChars || text.count('\n') > maxLines; -} - -static void showMessageDialog(QWidget *parent, - QMessageBox::Icon icon, - const QString &title, - const QString &text) { - if (!isLongMessage(text)) { - QMessageBox box(parent); - box.setIcon(icon); - box.setWindowTitle(title); - box.setText(text); - box.setStandardButtons(QMessageBox::Ok); - box.exec(); - return; - } - - QDialog dialog(parent); - dialog.setWindowTitle(title); - dialog.setModal(true); - - QVBoxLayout *layout = new QVBoxLayout(&dialog); - QHBoxLayout *header = new QHBoxLayout(); - - QLabel *iconLabel = new QLabel(&dialog); - QIcon iconObj = QApplication::style()->standardIcon( - icon == QMessageBox::Warning ? QStyle::SP_MessageBoxWarning : - icon == QMessageBox::Critical ? QStyle::SP_MessageBoxCritical : - QStyle::SP_MessageBoxInformation); - iconLabel->setPixmap(iconObj.pixmap(32, 32)); - header->addWidget(iconLabel); - - QLabel *titleLabel = new QLabel("Message is long. See details below.", &dialog); - titleLabel->setWordWrap(true); - header->addWidget(titleLabel, 1); - layout->addLayout(header); - - QPlainTextEdit *details = new QPlainTextEdit(text, &dialog); - details->setReadOnly(true); - details->setLineWrapMode(QPlainTextEdit::NoWrap); - layout->addWidget(details, 1); - - QDialogButtonBox *buttons = new QDialogButtonBox(QDialogButtonBox::Ok, &dialog); - QObject::connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); - layout->addWidget(buttons); - - QScreen *screen = parent ? parent->screen() : QGuiApplication::primaryScreen(); - QRect avail = screen ? screen->availableGeometry() : QRect(0, 0, 1200, 800); - dialog.resize(int(avail.width() * 0.8), int(avail.height() * 0.8)); - dialog.exec(); -} - -static void showWarning(QWidget *parent, const QString &title, const QString &text) { - showMessageDialog(parent, QMessageBox::Warning, title, text); -} - -static void showInfo(QWidget *parent, const QString &title, const QString &text) { - showMessageDialog(parent, QMessageBox::Information, title, text); -} - -static void showError(QWidget *parent, const QString &title, const QString &text) { - showMessageDialog(parent, QMessageBox::Critical, title, text); -} - -class ReorderTableView : public QTableView { - Q_OBJECT -public: - explicit ReorderTableView(QWidget *parent = nullptr) : QTableView(parent) {} - void setAllowDeleteShortcut(bool enabled) { allowDeleteShortcut_ = enabled; } - void setIconHitTest(const std::function &fn) { - iconHitTest_ = fn; - } - -signals: - void dragSwapRequested(int sourceRow, int targetRow); - void cellClicked(const QModelIndex &index, const QPoint &pos); - void deleteRequested(); - -protected: - void paintEvent(QPaintEvent *event) override { - QTableView::paintEvent(event); - if (!dragging_ || dragTargetRow_ < 0 || !model()) return; - const QModelIndex idx = model()->index(dragTargetRow_, 0); - if (!idx.isValid()) return; - QRect rowRect = visualRect(idx); - if (!rowRect.isValid()) return; - QPainter painter(viewport()); - QColor lineColor(90, 160, 255); - QPen pen(lineColor, 2); - painter.setPen(pen); - const int y = rowRect.bottom(); - painter.drawLine(0, y, viewport()->width(), y); - } - - void mousePressEvent(QMouseEvent *event) override { - if (event->button() == Qt::LeftButton) { - pressPos_ = event->pos(); - pressRow_ = indexAt(pressPos_).row(); - dragging_ = false; - dragTargetRow_ = -1; - const QModelIndex index = indexAt(event->pos()); - if (index.isValid() && iconHitTest_ && iconHitTest_(index, event->pos())) { - suppressReleaseClick_ = true; - suppressDoubleClick_ = true; - if (selectionModel()) { - selectionModel()->setCurrentIndex( - index, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); - } - emit cellClicked(index, event->pos()); - event->accept(); - return; - } - } - QTableView::mousePressEvent(event); - } - - void mouseMoveEvent(QMouseEvent *event) override { - if (!(event->buttons() & Qt::LeftButton) || pressRow_ < 0) { - QTableView::mouseMoveEvent(event); - return; - } - if (!dragging_) { - if ((event->pos() - pressPos_).manhattanLength() < QApplication::startDragDistance()) { - QTableView::mouseMoveEvent(event); - return; - } - dragging_ = true; - setCursor(Qt::ClosedHandCursor); - } - int targetRow = indexAt(event->pos()).row(); - if (targetRow < 0 && model()) { - if (event->pos().y() < 0) { - targetRow = 0; - } else if (event->pos().y() > viewport()->height()) { - targetRow = model()->rowCount() - 1; - } - } - if (targetRow != dragTargetRow_) { - dragTargetRow_ = targetRow; - viewport()->update(); - } - QTableView::mouseMoveEvent(event); - } - - void mouseReleaseEvent(QMouseEvent *event) override { - if (dragging_ && event->button() == Qt::LeftButton) { - int targetRow = indexAt(event->pos()).row(); - if (targetRow < 0 && model()) { - targetRow = model()->rowCount() - 1; - } - if (pressRow_ >= 0 && targetRow >= 0 && pressRow_ != targetRow) { - emit dragSwapRequested(pressRow_, targetRow); - } - } - dragging_ = false; - pressRow_ = -1; - dragTargetRow_ = -1; - viewport()->update(); - unsetCursor(); - QTableView::mouseReleaseEvent(event); - if (event->button() == Qt::LeftButton) { - if (suppressReleaseClick_) { - suppressReleaseClick_ = false; - return; - } - const QModelIndex index = indexAt(event->pos()); - if (index.isValid()) { - emit cellClicked(index, event->pos()); - } - } - } - - void mouseDoubleClickEvent(QMouseEvent *event) override { - if (suppressDoubleClick_) { - suppressDoubleClick_ = false; - event->accept(); - return; - } - const QModelIndex index = indexAt(event->pos()); - if (event->button() == Qt::LeftButton && index.isValid() && iconHitTest_) { - if (iconHitTest_(index, event->pos())) { - event->accept(); - return; - } - } - QTableView::mouseDoubleClickEvent(event); - } - - void keyPressEvent(QKeyEvent *event) override { - if (allowDeleteShortcut_ && - (event->key() == Qt::Key_Delete || event->key() == Qt::Key_Backspace)) { - if (state() != QAbstractItemView::EditingState) { - emit deleteRequested(); - event->accept(); - return; - } - } - QTableView::keyPressEvent(event); - } - -private: - QPoint pressPos_; - int pressRow_ = -1; - bool dragging_ = false; - int dragTargetRow_ = -1; - bool allowDeleteShortcut_ = false; - std::function iconHitTest_; - bool suppressReleaseClick_ = false; - bool suppressDoubleClick_ = false; -}; - -class DbClient { -public: - bool connect(const DbConfig &cfg, QString *error) { - try { - session_ = std::make_unique( - mysqlx::SessionOption::HOST, cfg.host.toStdString(), - mysqlx::SessionOption::PORT, cfg.port, - mysqlx::SessionOption::USER, cfg.user.toStdString(), - mysqlx::SessionOption::PWD, cfg.pass.toStdString(), - mysqlx::SessionOption::DB, cfg.schema.toStdString()); - logLine(QString("CONNECTED %1@%2:%3/%4") - .arg(cfg.user) - .arg(cfg.host) - .arg(cfg.port) - .arg(cfg.schema)); - schemaName_ = cfg.schema; - connected_ = true; - return true; - } catch (const mysqlx::Error &e) { - if (error) *error = QString::fromStdString(e.what()); - } catch (...) { - if (error) *error = "Unknown database error"; - } - connected_ = false; - return false; - } - - void close() { - if (session_) { - try { - session_->close(); - logLine("DISCONNECTED"); - } catch (...) { - } - } - session_.reset(); - connected_ = false; - } - - bool isOpen() const { return connected_ && session_ != nullptr; } - - bool loadColumns(const QString &tableName, QList &columns, QString *error) { - columns.clear(); - if (!isOpen()) { - if (error) *error = "Not connected"; - return false; - } - try { - const std::string sql = "SHOW COLUMNS FROM `" + tableName.toStdString() + "`"; - logSql(QString::fromStdString(sql), {}); - mysqlx::SqlResult result = session_->sql(sql).execute(); - for (mysqlx::Row row : result) { - if (row.colCount() < 6) continue; - ColumnMeta meta; - meta.name = QString::fromStdString(row[0].get()); - meta.type = QString::fromStdString(row[1].get()); - meta.nullable = QString::fromStdString(row[2].get()).toUpper() == "YES"; - meta.key = QString::fromStdString(row[3].get()); - if (row[4].getType() == mysqlx::Value::VNULL) { - meta.defaultValue = QVariant(); - } else { - meta.defaultValue = mysqlValueToVariant(row[4]); - } - meta.extra = QString::fromStdString(row[5].get()); - columns.append(meta); - } - return !columns.isEmpty(); - } catch (const mysqlx::Error &e) { - if (error) *error = QString::fromStdString(e.what()); - } catch (...) { - if (error) *error = "Failed to read columns"; - } - return false; - } - - bool fetchRows(const QString &tableName, - const QList &columns, - const QString &fixedFilterColumn, - const QString &fixedFilterValue, - const QString &searchColumn, - const QString &searchValue, - const QString &orderByColumn, - QList> &rows, - QString *error) { - rows.clear(); - if (!isOpen()) { - if (error) *error = "Not connected"; - return false; - } - try { - QStringList selectCols; - selectCols.reserve(columns.size()); - for (const ColumnMeta &meta : columns) { - if (meta.isDateTime()) { - selectCols << QString("CAST(`%1` AS CHAR) AS `%1`").arg(meta.name); - } else { - selectCols << QString("`%1`").arg(meta.name); - } - } - QString sql = QString("SELECT %1 FROM `%2`").arg(selectCols.join(", "), tableName); - QStringList conditions; - QList binds; - if (!fixedFilterColumn.isEmpty() && !fixedFilterValue.isEmpty()) { - conditions << QString("`%1` = ?").arg(fixedFilterColumn); - binds << fixedFilterValue; - } - if (!searchColumn.isEmpty() && !searchValue.isEmpty()) { - conditions << QString("LOWER(`%1`) LIKE ?").arg(searchColumn); - binds << QString("%%%1%%").arg(searchValue.toLower()); - } - if (!conditions.isEmpty()) { - sql += " WHERE " + conditions.join(" AND "); - } - if (!orderByColumn.isEmpty()) { - sql += QString(" ORDER BY `%1`").arg(orderByColumn); - } - mysqlx::SqlStatement stmt = session_->sql(sql.toStdString()); - logSql(sql, binds); - for (const QString &bind : binds) { - stmt.bind(bind.toStdString()); - } - mysqlx::SqlResult result = stmt.execute(); - for (mysqlx::Row row : result) { - QList rowValues; - rowValues.reserve(static_cast(row.colCount())); - for (mysqlx::col_count_t i = 0; i < row.colCount(); ++i) { - rowValues.append(mysqlValueToVariant(row[i])); - } - rows.append(rowValues); - } - return true; - } catch (const mysqlx::Error &e) { - if (error) *error = QString::fromStdString(e.what()); - } catch (...) { - if (error) *error = "Failed to read rows"; - } - return false; - } - - bool insertRecord(const QString &tableName, - const QList &columns, - const QVariantMap &values, - const QSet &nullColumns, - QString *error) { - if (!isOpen()) { - if (error) *error = "Not connected"; - return false; - } - - QStringList cols; - QStringList vals; - QList binds; - - for (const ColumnMeta &meta : columns) { - QVariant raw; - bool hasValue = values.contains(meta.name); - if (!hasValue) { - const QString actualKey = findKeyCaseInsensitive(values, meta.name); - if (values.contains(actualKey)) { - raw = values.value(actualKey); - hasValue = true; - } - } else { - raw = values.value(meta.name); - } - const QString text = raw.toString(); - const bool hasText = !text.isEmpty(); - - if (meta.isAutoIncrement() && !hasText && !setContainsCaseInsensitive(nullColumns, meta.name)) { - continue; - } - - if (setContainsCaseInsensitive(nullColumns, meta.name)) { - cols << QString("`%1`").arg(meta.name); - vals << "NULL"; - continue; - } - - if (!hasText) { - if (meta.hasDefault()) { - continue; - } - if (meta.isDateTime()) { - cols << QString("`%1`").arg(meta.name); - vals << "?"; - binds << QDateTime::currentDateTime().toString("yyyy-MM-dd HH:mm:ss.zzz"); - continue; - } - if (meta.nullable) { - cols << QString("`%1`").arg(meta.name); - vals << "NULL"; - continue; - } - if (error) *error = QString("Missing required value for %1").arg(meta.name); - return false; - } - - cols << QString("`%1`").arg(meta.name); - vals << "?"; - binds << text; - } - - if (cols.isEmpty()) { - if (error) *error = "No values to insert"; - return false; - } - - const QString sql = QString("INSERT INTO `%1` (%2) VALUES (%3)") - .arg(tableName) - .arg(cols.join(", ")) - .arg(vals.join(", ")); - try { - logLine("START TRANSACTION"); - logSql(sql, toStringList(binds)); - session_->startTransaction(); - mysqlx::SqlStatement stmt = session_->sql(sql.toStdString()); - for (const QVariant &val : binds) { - stmt.bind(val.toString().toStdString()); - } - stmt.execute(); - session_->commit(); - logLine("COMMIT"); - return true; - } catch (const mysqlx::Error &e) { - try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} - if (error) *error = QString::fromStdString(e.what()); - } catch (...) { - try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} - if (error) *error = "Insert failed"; - } - return false; - } - - bool updateRecord(const QString &tableName, - const QList &columns, - const QVariantMap &values, - const QSet &nullColumns, - const QVariantMap &keyValues, - QString *error) { - if (!isOpen()) { - if (error) *error = "Not connected"; - return false; - } - - QStringList sets; - QList binds; - - for (const ColumnMeta &meta : columns) { - QVariant raw; - bool hasValue = values.contains(meta.name); - if (!hasValue) { - const QString actualKey = findKeyCaseInsensitive(values, meta.name); - if (values.contains(actualKey)) { - raw = values.value(actualKey); - hasValue = true; - } - } else { - raw = values.value(meta.name); - } - const QString text = raw.toString(); - const bool hasText = !text.isEmpty(); - - if (setContainsCaseInsensitive(nullColumns, meta.name)) { - sets << QString("`%1`=NULL").arg(meta.name); - continue; - } - - if (!hasText) { - if (meta.nullable) { - sets << QString("`%1`=?").arg(meta.name); - binds << QString(); - continue; - } - if (error) *error = QString("Missing required value for %1").arg(meta.name); - return false; - } - - sets << QString("`%1`=?").arg(meta.name); - binds << text; - } - - if (sets.isEmpty()) { - if (error) *error = "No values to update"; - return false; - } - - QStringList where; - for (auto it = keyValues.begin(); it != keyValues.end(); ++it) { - where << QString("`%1`=?").arg(it.key()); - binds << it.value(); - } - - if (where.isEmpty()) { - if (error) *error = "Missing primary key values"; - return false; - } - - const QString sql = QString("UPDATE `%1` SET %2 WHERE %3") - .arg(tableName) - .arg(sets.join(", ")) - .arg(where.join(" AND ")); - try { - logLine("START TRANSACTION"); - logSql(sql, toStringList(binds)); - session_->startTransaction(); - mysqlx::SqlStatement stmt = session_->sql(sql.toStdString()); - for (const QVariant &val : binds) { - stmt.bind(val.toString().toStdString()); - } - stmt.execute(); - session_->commit(); - logLine("COMMIT"); - return true; - } catch (const mysqlx::Error &e) { - try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} - if (error) *error = QString::fromStdString(e.what()); - } catch (...) { - try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} - if (error) *error = "Update failed"; - } - return false; - } - - bool updateColumnByKeyBatch(const QString &tableName, - const QString &columnName, - const QList &keyValuesList, - const QList &values, - QString *error) { - if (!isOpen()) { - if (error) *error = "Not connected"; - return false; - } - if (keyValuesList.size() != values.size()) { - if (error) *error = "Batch size mismatch"; - return false; - } - try { - logLine("START TRANSACTION"); - session_->startTransaction(); - for (int i = 0; i < keyValuesList.size(); ++i) { - const QVariantMap &keyValues = keyValuesList.at(i); - if (keyValues.isEmpty()) { - session_->rollback(); - if (error) *error = "Missing primary key values"; - return false; - } - QStringList where; - QList binds; - binds << values.at(i).toString(); - for (auto it = keyValues.begin(); it != keyValues.end(); ++it) { - where << QString("`%1`=?").arg(it.key()); - binds << it.value().toString(); - } - const QString sql = QString("UPDATE `%1` SET `%2`=? WHERE %3") - .arg(tableName, columnName, where.join(" AND ")); - mysqlx::SqlStatement stmt = session_->sql(sql.toStdString()); - logSql(sql, binds); - for (const QString &bind : binds) { - stmt.bind(bind.toStdString()); - } - stmt.execute(); - } - session_->commit(); - logLine("COMMIT"); - return true; - } catch (const mysqlx::Error &e) { - try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} - if (error) *error = QString::fromStdString(e.what()); - } catch (...) { - try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} - if (error) *error = "Batch update failed"; - } - return false; - } - - bool updateObsOrderByObservationId(const QString &tableName, - const QList &obsIds, - const QList &orderValues, - QString *error) { - if (!isOpen()) { - if (error) *error = "Not connected"; - return false; - } - if (obsIds.size() != orderValues.size() || obsIds.isEmpty()) { - if (error) *error = "Invalid OBS_ORDER update data"; - return false; - } - try { - logLine("START TRANSACTION"); - session_->startTransaction(); - - long long maxOrder = 0; - try { - const QString maxSql = QString("SELECT MAX(`OBS_ORDER`) FROM `%1`").arg(tableName); - logSql(maxSql, {}); - mysqlx::SqlResult maxRes = session_->sql(maxSql.toStdString()).execute(); - mysqlx::Row maxRow = maxRes.fetchOne(); - if (maxRow && maxRow.colCount() > 0 && maxRow[0].getType() != mysqlx::Value::VNULL) { - maxOrder = maxRow[0].get(); - } - } catch (...) { - maxOrder = 0; - } - const long long offset = maxOrder + 1000; - - QStringList inParts; - inParts.reserve(obsIds.size()); - for (int i = 0; i < obsIds.size(); ++i) { - inParts << "?"; - } - - const QString bumpSql = QString("UPDATE `%1` SET `OBS_ORDER` = `OBS_ORDER` + ? WHERE `OBSERVATION_ID` IN (%2)") - .arg(tableName, inParts.join(", ")); - mysqlx::SqlStatement bumpStmt = session_->sql(bumpSql.toStdString()); - QList bumpBinds; - bumpBinds << QString::number(offset); - for (const QVariant &obsId : obsIds) bumpBinds << obsId.toString(); - logSql(bumpSql, bumpBinds); - bumpStmt.bind(QString::number(offset).toStdString()); - for (const QVariant &obsId : obsIds) { - bumpStmt.bind(obsId.toString().toStdString()); - } - bumpStmt.execute(); - - QStringList caseParts; - caseParts.reserve(obsIds.size()); - for (int i = 0; i < obsIds.size(); ++i) { - caseParts << "WHEN ? THEN ?"; - } - const QString finalSql = QString("UPDATE `%1` SET `OBS_ORDER` = CASE `OBSERVATION_ID` %2 END WHERE `OBSERVATION_ID` IN (%3)") - .arg(tableName, caseParts.join(" "), inParts.join(", ")); - mysqlx::SqlStatement finalStmt = session_->sql(finalSql.toStdString()); - QList finalBinds; - for (int i = 0; i < obsIds.size(); ++i) { - finalBinds << obsIds.at(i).toString() << orderValues.at(i).toString(); - } - for (const QVariant &obsId : obsIds) { - finalBinds << obsId.toString(); - } - logSql(finalSql, finalBinds); - for (int i = 0; i < obsIds.size(); ++i) { - finalStmt.bind(obsIds.at(i).toString().toStdString()); - finalStmt.bind(orderValues.at(i).toString().toStdString()); - } - for (const QVariant &obsId : obsIds) { - finalStmt.bind(obsId.toString().toStdString()); - } - finalStmt.execute(); - session_->commit(); - logLine("COMMIT"); - return true; - } catch (const mysqlx::Error &e) { - try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} - if (error) *error = QString::fromStdString(e.what()); - } catch (...) { - try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} - if (error) *error = "OBS_ORDER update failed"; - } - return false; - } - - bool updateObsOrderByCompositeKey(const QString &tableName, - const QList &obsIds, - const QList &slitWidths, - const QList &orderValues, - QString *error) { - if (!isOpen()) { - if (error) *error = "Not connected"; - return false; - } - if (obsIds.size() != orderValues.size() || obsIds.size() != slitWidths.size() || obsIds.isEmpty()) { - if (error) *error = "Invalid OBS_ORDER update data"; - return false; - } - try { - logLine("START TRANSACTION"); - session_->startTransaction(); - - long long maxOrder = 0; - try { - const QString maxSql = QString("SELECT MAX(`OBS_ORDER`) FROM `%1`").arg(tableName); - logSql(maxSql, {}); - mysqlx::SqlResult maxRes = session_->sql(maxSql.toStdString()).execute(); - mysqlx::Row maxRow = maxRes.fetchOne(); - if (maxRow && maxRow.colCount() > 0 && maxRow[0].getType() != mysqlx::Value::VNULL) { - maxOrder = maxRow[0].get(); - } - } catch (...) { - maxOrder = 0; - } - const long long offset = maxOrder + 1000; - - QStringList tupleParts; - tupleParts.reserve(obsIds.size()); - for (int i = 0; i < obsIds.size(); ++i) { - tupleParts << "(?, ?)"; - } - - const QString bumpSql = QString("UPDATE `%1` SET `OBS_ORDER` = `OBS_ORDER` + ? " - "WHERE (`OBSERVATION_ID`, `OTMslitwidth`) IN (%2)") - .arg(tableName, tupleParts.join(", ")); - mysqlx::SqlStatement bumpStmt = session_->sql(bumpSql.toStdString()); - QList bumpBinds; - bumpBinds << QString::number(offset); - for (int i = 0; i < obsIds.size(); ++i) { - bumpBinds << obsIds.at(i).toString() << slitWidths.at(i).toString(); - } - logSql(bumpSql, bumpBinds); - bumpStmt.bind(QString::number(offset).toStdString()); - for (int i = 0; i < obsIds.size(); ++i) { - bumpStmt.bind(obsIds.at(i).toString().toStdString()); - bumpStmt.bind(slitWidths.at(i).toString().toStdString()); - } - bumpStmt.execute(); - - QStringList caseParts; - caseParts.reserve(obsIds.size()); - for (int i = 0; i < obsIds.size(); ++i) { - caseParts << "WHEN `OBSERVATION_ID`=? AND `OTMslitwidth`=? THEN ?"; - } - const QString finalSql = QString("UPDATE `%1` SET `OBS_ORDER` = CASE %2 END " - "WHERE (`OBSERVATION_ID`, `OTMslitwidth`) IN (%3)") - .arg(tableName, caseParts.join(" "), tupleParts.join(", ")); - mysqlx::SqlStatement finalStmt = session_->sql(finalSql.toStdString()); - QList finalBinds; - for (int i = 0; i < obsIds.size(); ++i) { - finalBinds << obsIds.at(i).toString() << slitWidths.at(i).toString() - << orderValues.at(i).toString(); - } - for (int i = 0; i < obsIds.size(); ++i) { - finalBinds << obsIds.at(i).toString() << slitWidths.at(i).toString(); - } - logSql(finalSql, finalBinds); - // CASE bindings - for (int i = 0; i < obsIds.size(); ++i) { - finalStmt.bind(obsIds.at(i).toString().toStdString()); - finalStmt.bind(slitWidths.at(i).toString().toStdString()); - finalStmt.bind(orderValues.at(i).toString().toStdString()); - } - // WHERE tuple bindings - for (int i = 0; i < obsIds.size(); ++i) { - finalStmt.bind(obsIds.at(i).toString().toStdString()); - finalStmt.bind(slitWidths.at(i).toString().toStdString()); - } - finalStmt.execute(); - - session_->commit(); - logLine("COMMIT"); - return true; - } catch (const mysqlx::Error &e) { - try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} - if (error) *error = QString::fromStdString(e.what()); - } catch (...) { - try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} - if (error) *error = "OBS_ORDER update failed"; - } - return false; - } - - bool moveObsOrder(const QString &tableName, - int setId, - const QVariant &obsId, - const QVariant &slitWidth, - int oldPos, - int newPos, - QString *error) { - if (!isOpen()) { - if (error) *error = "Not connected"; - return false; - } - if (oldPos == newPos) return true; - try { - logLine("START TRANSACTION"); - session_->startTransaction(); - if (newPos < oldPos) { - const QString shiftSql = QString("UPDATE `%1` SET `OBS_ORDER` = `OBS_ORDER` + 1 " - "WHERE `SET_ID` = ? AND `OBS_ORDER` >= ? AND `OBS_ORDER` < ?") - .arg(tableName); - mysqlx::SqlStatement shiftStmt = session_->sql(shiftSql.toStdString()); - logSql(shiftSql, {QString::number(setId), QString::number(newPos), QString::number(oldPos)}); - shiftStmt.bind(QString::number(setId).toStdString()); - shiftStmt.bind(QString::number(newPos).toStdString()); - shiftStmt.bind(QString::number(oldPos).toStdString()); - shiftStmt.execute(); - } else { - const QString shiftSql = QString("UPDATE `%1` SET `OBS_ORDER` = `OBS_ORDER` - 1 " - "WHERE `SET_ID` = ? AND `OBS_ORDER` <= ? AND `OBS_ORDER` > ?") - .arg(tableName); - mysqlx::SqlStatement shiftStmt = session_->sql(shiftSql.toStdString()); - logSql(shiftSql, {QString::number(setId), QString::number(newPos), QString::number(oldPos)}); - shiftStmt.bind(QString::number(setId).toStdString()); - shiftStmt.bind(QString::number(newPos).toStdString()); - shiftStmt.bind(QString::number(oldPos).toStdString()); - shiftStmt.execute(); - } - - const QString updateSql = QString("UPDATE `%1` SET `OBS_ORDER` = ? " - "WHERE `OBSERVATION_ID` = ? AND `OTMslitwidth` = ?") - .arg(tableName); - mysqlx::SqlStatement updateStmt = session_->sql(updateSql.toStdString()); - logSql(updateSql, {QString::number(newPos), obsId.toString(), slitWidth.toString()}); - updateStmt.bind(QString::number(newPos).toStdString()); - updateStmt.bind(obsId.toString().toStdString()); - updateStmt.bind(slitWidth.toString().toStdString()); - updateStmt.execute(); - - session_->commit(); - logLine("COMMIT"); - return true; - } catch (const mysqlx::Error &e) { - try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} - if (error) *error = QString::fromStdString(e.what()); - } catch (...) { - try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} - if (error) *error = "OBS_ORDER move failed"; - } - return false; - } - - bool swapTargets(const QString &tableName, - const QVariant &obsIdX, - const QVariant &orderX, - const QVariant &obsIdY, - const QVariant &orderY, - QString *error) { - if (!isOpen()) { - if (error) *error = "Not connected"; - return false; - } - if (obsIdX == obsIdY) return true; - try { - logLine("START TRANSACTION"); - session_->startTransaction(); - - long long maxObsId = 0; - long long maxOrder = 0; - try { - const QString maxObsSql = QString("SELECT MAX(`OBSERVATION_ID`) FROM `%1`").arg(tableName); - logSql(maxObsSql, {}); - mysqlx::SqlResult maxObsRes = session_->sql(maxObsSql.toStdString()).execute(); - mysqlx::Row maxObsRow = maxObsRes.fetchOne(); - if (maxObsRow && maxObsRow.colCount() > 0 && maxObsRow[0].getType() != mysqlx::Value::VNULL) { - maxObsId = maxObsRow[0].get(); - } - } catch (...) { - maxObsId = 0; - } - try { - const QString maxOrderSql = QString("SELECT MAX(`OBS_ORDER`) FROM `%1`").arg(tableName); - logSql(maxOrderSql, {}); - mysqlx::SqlResult maxOrderRes = session_->sql(maxOrderSql.toStdString()).execute(); - mysqlx::Row maxOrderRow = maxOrderRes.fetchOne(); - if (maxOrderRow && maxOrderRow.colCount() > 0 && maxOrderRow[0].getType() != mysqlx::Value::VNULL) { - maxOrder = maxOrderRow[0].get(); - } - } catch (...) { - maxOrder = 0; - } - const long long tempObsId = maxObsId + 100000; - const long long tempOrder = maxOrder + 100000; - - const QString bumpSql = QString("UPDATE `%1` SET `OBSERVATION_ID`=?, `OBS_ORDER`=? " - "WHERE `OBSERVATION_ID`=?") - .arg(tableName); - logSql(bumpSql, {QString::number(tempObsId), QString::number(tempOrder), - obsIdY.toString()}); - mysqlx::SqlStatement bumpStmt = session_->sql(bumpSql.toStdString()); - bumpStmt.bind(QString::number(tempObsId).toStdString()); - bumpStmt.bind(QString::number(tempOrder).toStdString()); - bumpStmt.bind(obsIdY.toString().toStdString()); - bumpStmt.execute(); - - const QString updateXSql = QString("UPDATE `%1` SET `OBSERVATION_ID`=?, `OBS_ORDER`=? " - "WHERE `OBSERVATION_ID`=?") - .arg(tableName); - logSql(updateXSql, {obsIdY.toString(), orderY.toString(), - obsIdX.toString()}); - mysqlx::SqlStatement updateX = session_->sql(updateXSql.toStdString()); - updateX.bind(obsIdY.toString().toStdString()); - updateX.bind(orderY.toString().toStdString()); - updateX.bind(obsIdX.toString().toStdString()); - updateX.execute(); - - const QString updateYSql = QString("UPDATE `%1` SET `OBSERVATION_ID`=?, `OBS_ORDER`=? " - "WHERE `OBSERVATION_ID`=?") - .arg(tableName); - logSql(updateYSql, {obsIdX.toString(), orderX.toString(), - QString::number(tempObsId)}); - mysqlx::SqlStatement updateY = session_->sql(updateYSql.toStdString()); - updateY.bind(obsIdX.toString().toStdString()); - updateY.bind(orderX.toString().toStdString()); - updateY.bind(QString::number(tempObsId).toStdString()); - updateY.execute(); - - session_->commit(); - logLine("COMMIT"); - return true; - } catch (const mysqlx::Error &e) { - try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} - if (error) *error = QString::fromStdString(e.what()); - } catch (...) { - try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} - if (error) *error = "Swap failed"; - } - return false; - } - - bool shiftObsOrderAfterDelete(const QString &tableName, - int setId, - int deletedPos, - QString *error) { - if (!isOpen()) { - if (error) *error = "Not connected"; - return false; - } - try { - const QString sql = QString("UPDATE `%1` SET `OBS_ORDER` = `OBS_ORDER` - 1 " - "WHERE `SET_ID` = ? AND `OBS_ORDER` > ?") - .arg(tableName); - mysqlx::SqlStatement stmt = session_->sql(sql.toStdString()); - logSql(sql, {QString::number(setId), QString::number(deletedPos)}); - stmt.bind(QString::number(setId).toStdString()); - stmt.bind(QString::number(deletedPos).toStdString()); - stmt.execute(); - return true; - } catch (const mysqlx::Error &e) { - if (error) *error = QString::fromStdString(e.what()); - } catch (...) { - if (error) *error = "OBS_ORDER shift failed"; - } - return false; - } - - bool shiftObsOrderForInsert(const QString &tableName, - int setId, - int startPos, - int count, - QString *error) { - if (!isOpen()) { - if (error) *error = "Not connected"; - return false; - } - if (count <= 0) return true; - try { - const QString sql = QString("UPDATE `%1` SET `OBS_ORDER` = `OBS_ORDER` + ? " - "WHERE `SET_ID` = ? AND `OBS_ORDER` >= ?") - .arg(tableName); - mysqlx::SqlStatement stmt = session_->sql(sql.toStdString()); - logSql(sql, {QString::number(count), QString::number(setId), QString::number(startPos)}); - stmt.bind(QString::number(count).toStdString()); - stmt.bind(QString::number(setId).toStdString()); - stmt.bind(QString::number(startPos).toStdString()); - stmt.execute(); - return true; - } catch (const mysqlx::Error &e) { - if (error) *error = QString::fromStdString(e.what()); - } catch (...) { - if (error) *error = "OBS_ORDER shift failed"; - } - return false; - } - - bool nextObsOrderForSet(const QString &tableName, - int setId, - int *nextOrder, - QString *error) { - if (!isOpen()) { - if (error) *error = "Not connected"; - return false; - } - if (!nextOrder) { - if (error) *error = "Missing output parameter"; - return false; - } - try { - const QString sql = QString("SELECT MAX(`OBS_ORDER`) FROM `%1` WHERE `SET_ID` = ?") - .arg(tableName); - logSql(sql, {QString::number(setId)}); - mysqlx::SqlStatement stmt = session_->sql(sql.toStdString()); - stmt.bind(QString::number(setId).toStdString()); - mysqlx::SqlResult res = stmt.execute(); - mysqlx::Row row = res.fetchOne(); - long long maxOrder = 0; - if (row && row.colCount() > 0 && row[0].getType() != mysqlx::Value::VNULL) { - maxOrder = row[0].get(); - } - *nextOrder = static_cast(maxOrder + 1); - return true; - } catch (const mysqlx::Error &e) { - if (error) *error = QString::fromStdString(e.what()); - } catch (...) { - if (error) *error = "Failed to read OBS_ORDER"; - } - return false; - } - - bool deleteRecordByKey(const QString &tableName, - const QVariantMap &keyValues, - QString *error) { - if (!isOpen()) { - if (error) *error = "Not connected"; - return false; - } - if (keyValues.isEmpty()) { - if (error) *error = "Missing primary key values"; - return false; - } - try { - QStringList where; - QList binds; - for (auto it = keyValues.begin(); it != keyValues.end(); ++it) { - where << QString("`%1`=?").arg(it.key()); - binds << it.value().toString(); - } - const QString sql = QString("DELETE FROM `%1` WHERE %2") - .arg(tableName, where.join(" AND ")); - mysqlx::SqlStatement stmt = session_->sql(sql.toStdString()); - logSql(sql, binds); - for (const QString &bind : binds) { - stmt.bind(bind.toStdString()); - } - stmt.execute(); - return true; - } catch (const mysqlx::Error &e) { - if (error) *error = QString::fromStdString(e.what()); - } catch (...) { - if (error) *error = "Delete failed"; - } - return false; - } - - bool deleteRecordsByColumn(const QString &tableName, - const QString &columnName, - const QVariant &value, - QString *error) { - if (!isOpen()) { - if (error) *error = "Not connected"; - return false; - } - try { - const QString sql = QString("DELETE FROM `%1` WHERE `%2`=?") - .arg(tableName, columnName); - mysqlx::SqlStatement stmt = session_->sql(sql.toStdString()); - logSql(sql, {value.toString()}); - stmt.bind(value.toString().toStdString()); - stmt.execute(); - return true; - } catch (const mysqlx::Error &e) { - if (error) *error = QString::fromStdString(e.what()); - } catch (...) { - if (error) *error = "Delete failed"; - } - return false; - } - - bool fetchSingleValue(const QString &sql, - const QList &binds, - QVariant *valueOut, - QString *error) { - if (!isOpen()) { - if (error) *error = "Not connected"; - return false; - } - try { - mysqlx::SqlStatement stmt = session_->sql(sql.toStdString()); - logSql(sql, toStringList(binds)); - for (const QVariant &val : binds) { - stmt.bind(val.toString().toStdString()); - } - mysqlx::SqlResult result = stmt.execute(); - for (mysqlx::Row row : result) { - if (row.colCount() > 0) { - if (valueOut) *valueOut = mysqlValueToVariant(row[0]); - return true; - } - break; - } - if (error) *error = "No results"; - } catch (const mysqlx::Error &e) { - if (error) *error = QString::fromStdString(e.what()); - } catch (...) { - if (error) *error = "Query failed"; - } - return false; - } - - bool updateColumnsByKey(const QString &tableName, - const QVariantMap &updates, - const QVariantMap &keyValues, - QString *error) { - if (!isOpen()) { - if (error) *error = "Not connected"; - return false; - } - if (updates.isEmpty()) { - if (error) *error = "No columns to update"; - return false; - } - if (keyValues.isEmpty()) { - if (error) *error = "Missing primary key values"; - return false; - } - try { - QStringList sets; - QList binds; - for (auto it = updates.begin(); it != updates.end(); ++it) { - const QVariant val = it.value(); - if (!val.isValid() || val.isNull()) { - sets << QString("`%1`=NULL").arg(it.key()); - } else { - sets << QString("`%1`=?").arg(it.key()); - binds << val; - } - } - QStringList where; - for (auto it = keyValues.begin(); it != keyValues.end(); ++it) { - where << QString("`%1`=?").arg(it.key()); - binds << it.value(); - } - const QString sql = QString("UPDATE `%1` SET %2 WHERE %3") - .arg(tableName) - .arg(sets.join(", ")) - .arg(where.join(" AND ")); - logLine("START TRANSACTION"); - logSql(sql, toStringList(binds)); - session_->startTransaction(); - mysqlx::SqlStatement stmt = session_->sql(sql.toStdString()); - for (const QVariant &val : binds) { - stmt.bind(val.toString().toStdString()); - } - stmt.execute(); - session_->commit(); - logLine("COMMIT"); - return true; - } catch (const mysqlx::Error &e) { - try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} - if (error) *error = QString::fromStdString(e.what()); - } catch (...) { - try { session_->rollback(); logLine("ROLLBACK"); } catch (...) {} - if (error) *error = "Update failed"; - } - return false; - } - -private: - std::unique_ptr session_; - QString schemaName_; - bool connected_ = false; - bool logSql_ = true; - - void logLine(const QString &line) const { - if (!logSql_) return; - qInfo().noquote() << line; - } - - static QString quoteBind(const QString &value) { - QString v = value; - v.replace('\'', "''"); - return "'" + v + "'"; - } - - static QString expandSql(const QString &sql, const QList &binds) { - QString out; - out.reserve(sql.size() + binds.size() * 4); - int bindIndex = 0; - for (QChar ch : sql) { - if (ch == '?' && bindIndex < binds.size()) { - out += quoteBind(binds.at(bindIndex++)); - } else { - out += ch; - } - } - if (bindIndex < binds.size()) { - QStringList extra; - for (int i = bindIndex; i < binds.size(); ++i) { - extra << quoteBind(binds.at(i)); - } - out += QString(" /* extra binds: %1 */").arg(extra.join(", ")); - } - return out; - } - - static QList toStringList(const QList &vars) { - QList out; - out.reserve(vars.size()); - for (const QVariant &v : vars) { - out << v.toString(); - } - return out; - } - - void logSql(const QString &sql, const QList &binds) const { - if (!logSql_) return; - qInfo().noquote() << "SQL:" << expandSql(sql, binds); - } -}; - -class RecordEditorDialog : public QDialog { - Q_OBJECT -public: - RecordEditorDialog(const QString &tableName, - const QList &columns, - const QVariantMap &initialValues, - bool isInsert, - QWidget *parent = nullptr) - : QDialog(parent), columns_(columns) { - setWindowTitle(isInsert ? QString("Add %1").arg(tableName) - : QString("Edit %1").arg(tableName)); - setModal(true); - - QVBoxLayout *layout = new QVBoxLayout(this); - QFormLayout *form = new QFormLayout(); - - for (const ColumnMeta &meta : columns_) { - QWidget *fieldWidget = new QWidget(this); - QHBoxLayout *fieldLayout = new QHBoxLayout(fieldWidget); - fieldLayout->setContentsMargins(0, 0, 0, 0); - - QLineEdit *edit = new QLineEdit(fieldWidget); - QCheckBox *nullCheck = nullptr; - - const QVariant val = initialValues.value(meta.name); - if (val.isValid() && !val.isNull()) { - edit->setText(val.toString()); - } else if (!val.isValid() || val.isNull()) { - edit->setText(QString()); - } - - if (meta.nullable) { - nullCheck = new QCheckBox("NULL", fieldWidget); - if (!val.isValid() || val.isNull()) { - nullCheck->setChecked(true); - edit->setEnabled(false); - } - connect(nullCheck, &QCheckBox::toggled, edit, &QWidget::setDisabled); - } - - if (isInsert && meta.isAutoIncrement() && edit->text().isEmpty()) { - edit->setPlaceholderText("AUTO"); - } - - fieldLayout->addWidget(edit, 1); - if (nullCheck) fieldLayout->addWidget(nullCheck); - - QString label = meta.name + " (" + meta.type + ")"; - if (!meta.nullable) label += " *"; - form->addRow(label, fieldWidget); - - edits_.insert(meta.name, edit); - if (nullCheck) nullChecks_.insert(meta.name, nullCheck); - } - - layout->addLayout(form); - - QHBoxLayout *buttons = new QHBoxLayout(); - buttons->addStretch(); - QPushButton *cancel = new QPushButton("Cancel", this); - QPushButton *ok = new QPushButton("Save", this); - connect(cancel, &QPushButton::clicked, this, &QDialog::reject); - connect(ok, &QPushButton::clicked, this, &QDialog::accept); - buttons->addWidget(cancel); - buttons->addWidget(ok); - layout->addLayout(buttons); - } - - QVariantMap values() const { - QVariantMap map; - for (const ColumnMeta &meta : columns_) { - QLineEdit *edit = edits_.value(meta.name); - if (!edit) continue; - map.insert(meta.name, edit->text()); - } - return map; - } - - QSet nullColumns() const { - QSet cols; - for (auto it = nullChecks_.begin(); it != nullChecks_.end(); ++it) { - if (it.value()->isChecked()) { - cols.insert(it.key()); - } - } - return cols; - } - -private: - QList columns_; - QHash edits_; - QHash nullChecks_; -}; - -struct OtmSettings { - double seeingFwhm = 1.1; - double seeingPivot = 500.0; - double airmassMax = 4.0; - bool useSkySim = true; - QString pythonCmd; -}; - -class OtmSettingsDialog : public QDialog { - Q_OBJECT -public: - explicit OtmSettingsDialog(const OtmSettings &initial, QWidget *parent = nullptr) - : QDialog(parent) { - setWindowTitle("OTM Settings"); - setModal(true); - - QVBoxLayout *layout = new QVBoxLayout(this); - QFormLayout *form = new QFormLayout(); - - pythonEdit_ = new QLineEdit(initial.pythonCmd, this); - pythonEdit_->setPlaceholderText("auto-detect (python3)"); - pythonEdit_->setToolTip("Optional. Leave blank to auto-detect."); - form->addRow("Python (OTM)", pythonEdit_); - - seeingFwhm_ = new QDoubleSpinBox(this); - seeingFwhm_->setRange(0.1, 10.0); - seeingFwhm_->setDecimals(3); - seeingFwhm_->setValue(initial.seeingFwhm); - form->addRow("Seeing FWHM (arcsec)", seeingFwhm_); - - seeingPivot_ = new QDoubleSpinBox(this); - seeingPivot_->setRange(100.0, 2000.0); - seeingPivot_->setDecimals(1); - seeingPivot_->setValue(initial.seeingPivot); - form->addRow("Seeing Pivot (nm)", seeingPivot_); - - airmassMax_ = new QDoubleSpinBox(this); - airmassMax_->setRange(1.0, 10.0); - airmassMax_->setDecimals(2); - airmassMax_->setValue(initial.airmassMax); - form->addRow("Airmass Max", airmassMax_); - - useSkySim_ = new QCheckBox("Use sky simulation", this); - useSkySim_->setChecked(initial.useSkySim); - form->addRow(useSkySim_); - - layout->addLayout(form); - - QHBoxLayout *buttons = new QHBoxLayout(); - buttons->addStretch(); - QPushButton *cancel = new QPushButton("Cancel", this); - QPushButton *ok = new QPushButton("Run", this); - ok->setDefault(true); - ok->setAutoDefault(true); - cancel->setAutoDefault(false); - connect(cancel, &QPushButton::clicked, this, &QDialog::reject); - connect(ok, &QPushButton::clicked, this, &QDialog::accept); - buttons->addWidget(cancel); - buttons->addWidget(ok); - layout->addLayout(buttons); - } - - OtmSettings settings() const { - OtmSettings s; - s.seeingFwhm = seeingFwhm_->value(); - s.seeingPivot = seeingPivot_->value(); - s.airmassMax = airmassMax_->value(); - s.useSkySim = useSkySim_->isChecked(); - s.pythonCmd = pythonEdit_->text().trimmed(); - return s; - } - -private: - QLineEdit *pythonEdit_ = nullptr; - QDoubleSpinBox *seeingFwhm_ = nullptr; - QDoubleSpinBox *seeingPivot_ = nullptr; - QDoubleSpinBox *airmassMax_ = nullptr; - QCheckBox *useSkySim_ = nullptr; -}; - -class TablePanel : public QWidget { - Q_OBJECT -public: - TablePanel(const QString &title, QWidget *parent = nullptr) - : QWidget(parent) { - QVBoxLayout *layout = new QVBoxLayout(this); - - QHBoxLayout *topBar = new QHBoxLayout(); - QLabel *titleLabel = new QLabel("" + title + "", this); - topBar->addWidget(titleLabel); - topBar->addStretch(); - - refreshButton_ = new QPushButton("Refresh", this); - addButton_ = new QPushButton("Add", this); - topBar->addWidget(refreshButton_); - topBar->addWidget(addButton_); - - layout->addLayout(topBar); - - QHBoxLayout *filterBar = new QHBoxLayout(); - searchLabel_ = new QLabel("Search:", this); - searchEdit_ = new QLineEdit(this); - searchApply_ = new QPushButton("Search", this); - searchClear_ = new QPushButton("Clear", this); - searchLabel_->setVisible(false); - searchEdit_->setVisible(false); - searchApply_->setVisible(false); - searchClear_->setVisible(false); - - filterBar->addWidget(searchLabel_); - filterBar->addWidget(searchEdit_); - filterBar->addWidget(searchApply_); - filterBar->addWidget(searchClear_); - - filterBar->addStretch(); - - layout->addLayout(filterBar); - - model_ = new QStandardItemModel(this); - view_ = new ReorderTableView(this); - view_->setModel(model_); - view_->setSelectionBehavior(QAbstractItemView::SelectRows); - view_->setSelectionMode(QAbstractItemView::SingleSelection); - view_->setEditTriggers(QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed); - view_->horizontalHeader()->setStretchLastSection(false); - view_->horizontalHeader()->setSectionResizeMode(QHeaderView::Interactive); - view_->setSortingEnabled(sortingEnabled_); - view_->setContextMenuPolicy(Qt::CustomContextMenu); - view_->setAllowDeleteShortcut(allowDelete_); - layout->addWidget(view_, 1); - - statusLabel_ = new QLabel("Not connected", this); - layout->addWidget(statusLabel_); - - connect(refreshButton_, &QPushButton::clicked, this, &TablePanel::refresh); - connect(addButton_, &QPushButton::clicked, this, &TablePanel::addRecord); - connect(searchApply_, &QPushButton::clicked, this, &TablePanel::refresh); - connect(searchClear_, &QPushButton::clicked, this, &TablePanel::clearSearch); - connect(view_, &QWidget::customContextMenuRequested, this, &TablePanel::showContextMenu); - connect(view_, &ReorderTableView::dragSwapRequested, this, &TablePanel::handleDragSwap); - connect(view_, &ReorderTableView::cellClicked, this, &TablePanel::handleCellClick); - connect(view_, &ReorderTableView::deleteRequested, this, &TablePanel::handleDeleteShortcut); - connect(model_, &QStandardItemModel::itemChanged, this, &TablePanel::handleItemChanged); - view_->horizontalHeader()->setContextMenuPolicy(Qt::CustomContextMenu); - connect(view_->horizontalHeader(), &QHeaderView::customContextMenuRequested, - this, &TablePanel::showColumnHeaderContextMenu); - view_->horizontalHeader()->installEventFilter(this); - if (view_->horizontalHeader()->viewport()) { - view_->horizontalHeader()->viewport()->installEventFilter(this); - } - connect(view_->selectionModel(), &QItemSelectionModel::currentRowChanged, this, - [this](const QModelIndex &, const QModelIndex &) { emit selectionChanged(); }); - view_->setIconHitTest([this](const QModelIndex &index, const QPoint &pos) { - if (!groupingEnabled_) return false; - if (!index.isValid()) return false; - const int nameCol = columnIndex("NAME"); - if (nameCol < 0 || index.column() != nameCol) return false; - const int row = index.row(); - if (!isGroupHeaderRow(row)) return false; - QRect cellRect = view_->visualRect(index); - const int iconSize = view_->style()->pixelMetric(QStyle::PM_SmallIconSize); - QRect iconRect(cellRect.left() + 4, - cellRect.center().y() - iconSize / 2, - iconSize, iconSize); - return iconRect.contains(pos); - }); - - headerSaveTimer_ = new QTimer(this); - headerSaveTimer_->setSingleShot(true); - connect(headerSaveTimer_, &QTimer::timeout, this, &TablePanel::saveHeaderState); - QHeaderView *header = view_->horizontalHeader(); - connect(header, &QHeaderView::sectionResized, this, - [this](int, int, int) { scheduleHeaderStateSave(); }); - connect(header, &QHeaderView::sectionMoved, this, - [this](int, int, int) { scheduleHeaderStateSave(); }); - } - - ~TablePanel() override { - if (headerSaveTimer_ && headerSaveTimer_->isActive()) { - headerSaveTimer_->stop(); - } - saveHeaderState(); - } - - void setDatabase(DbClient *db, const QString &tableName) { - db_ = db; - tableName_ = tableName; - columns_.clear(); - headerStateLoaded_ = false; - groupingStateLoaded_ = false; - manualUngroupObsIds_.clear(); - manualGroupKeyByObsId_.clear(); - refresh(); - } - - void setSearchColumn(const QString &columnName) { - searchColumn_ = columnName; - const bool enabled = !searchColumn_.isEmpty(); - searchLabel_->setVisible(enabled); - searchEdit_->setVisible(enabled); - searchApply_->setVisible(enabled); - searchClear_->setVisible(enabled); - if (enabled) { - searchLabel_->setText(QString("Search %1:").arg(searchColumn_)); - } - } - - void setFixedFilter(const QString &columnName, const QString &value) { - fixedFilterColumn_ = columnName; - fixedFilterValue_ = value; - } - - void clearFixedFilter() { - fixedFilterColumn_.clear(); - fixedFilterValue_.clear(); - } - - QString fixedFilterColumn() const { return fixedFilterColumn_; } - QString fixedFilterValue() const { return fixedFilterValue_; } - - void setOrderByColumn(const QString &columnName) { - orderByColumn_ = columnName; - } - - void setSortingEnabled(bool enabled) { - sortingEnabled_ = enabled; - if (view_) view_->setSortingEnabled(sortingEnabled_); - } - - void setAllowReorder(bool enabled) { - allowReorder_ = enabled; - if (!view_) return; - if (allowReorder_) { - view_->setDragDropMode(QAbstractItemView::NoDragDrop); - view_->setDragEnabled(false); - view_->setAcceptDrops(false); - view_->setDropIndicatorShown(false); - } else { - view_->setDragDropMode(QAbstractItemView::NoDragDrop); - view_->setDragEnabled(false); - view_->setAcceptDrops(false); - } - } - - void setAllowDelete(bool enabled) { - allowDelete_ = enabled; - if (view_) view_->setAllowDeleteShortcut(enabled); - } - - void setAllowColumnHeaderBulkEdit(bool enabled) { allowColumnHeaderBulkEdit_ = enabled; } - - void setRowNormalizer(const std::function &)> &normalizer) { - normalizer_ = normalizer; - } - - void setQuickAddEnabled(bool enabled) { quickAddEnabled_ = enabled; } - - void setQuickAddBuilder(const std::function &, QString *)> &builder) { - quickAddBuilder_ = builder; - } - - void setQuickAddInsertAtTop(bool enabled) { quickAddInsertAtTop_ = enabled; } - - void setGroupingEnabled(bool enabled) { - groupingEnabled_ = enabled; - applyGrouping(); - } - - void setHiddenColumns(const QStringList &columns) { - hiddenColumns_.clear(); - for (const QString &name : columns) { - hiddenColumns_ << name.toUpper(); - } - applyHiddenColumns(); - } - - void showContextMenuForObsId(const QString &obsId, const QPoint &globalPos) { - if (obsId.isEmpty()) return; - const int row = findRowByColumnValue("OBSERVATION_ID", obsId); - if (row < 0) return; - showContextMenuAtRow(row, globalPos); - } - - void setColumnAfterRules(const QVector> &rules) { - columnAfterRules_.clear(); - for (const auto &rule : rules) { - columnAfterRules_.append({rule.first.toUpper(), rule.second.toUpper()}); - } - headerRulesPending_ = true; - applyColumnOrderRules(); - } - - QVariantMap currentRowValues() const { - QVariantMap map; - const QModelIndex current = view_->currentIndex(); - if (!current.isValid()) return map; - const int row = current.row(); - for (int col = 0; col < columns_.size(); ++col) { - QStandardItem *item = model_->item(row, col); - if (!item) continue; - map.insert(columns_[col].name, item->data(Qt::EditRole)); - } - return map; - } - - bool selectRowByColumnValue(const QString &columnName, const QVariant &value) { - const int col = columnIndex(columnName); - if (col < 0) return false; - for (int row = 0; row < model_->rowCount(); ++row) { - QStandardItem *item = model_->item(row, col); - if (!item) continue; - const QVariant cellValue = item->data(Qt::UserRole + 1); - if (cellValue.toString() == value.toString()) { - const QModelIndex idx = model_->index(row, 0); - view_->selectionModel()->setCurrentIndex( - idx, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); - return true; - } - } - return false; - } - - QSet ungroupedObsIds() const { return manualUngroupObsIds_; } - - bool updateColumnForObsIds(const QStringList &obsIds, const QString &column, const QString &value, - QStringList *errors = nullptr) { - if (!db_ || !db_->isOpen()) { - if (errors) errors->append("Not connected"); - return false; - } - if (obsIds.isEmpty()) return false; - QVariantMap updates; - updates.insert(column, value); - bool ok = true; - for (const QString &obsId : obsIds) { - QVariantMap keyValues; - keyValues.insert("OBSERVATION_ID", obsId); - QString error; - if (!db_->updateColumnsByKey(tableName_, updates, keyValues, &error)) { - ok = false; - if (errors) { - errors->append(QString("%1: %2").arg(obsId, error.isEmpty() ? "Update failed" : error)); - } - } - } - if (ok) { - refreshWithState(captureViewState()); - emit dataMutated(); - } - return ok; - } - - QHash groupMembersByHeaderObsId() const { - QHash result; - if (!groupingEnabled_) return result; - const int obsIdCol = columnIndex("OBSERVATION_ID"); - if (obsIdCol < 0) return result; - for (auto it = groupRowsByKey_.begin(); it != groupRowsByKey_.end(); ++it) { - const QString key = it.key(); - const int headerRow = groupHeaderRowByKey_.value(key, -1); - if (headerRow < 0) continue; - QStandardItem *headerItem = model_->item(headerRow, obsIdCol); - if (!headerItem) continue; - const QString headerObsId = headerItem->data(Qt::UserRole + 1).toString(); - if (headerObsId.isEmpty()) continue; - QStringList members; - for (int row : it.value()) { - QStandardItem *obsItem = model_->item(row, obsIdCol); - if (!obsItem) continue; - const QString obsId = obsItem->data(Qt::UserRole + 1).toString(); - if (!obsId.isEmpty()) members.append(obsId); - } - if (!members.isEmpty()) { - result.insert(headerObsId, members); - } - } - return result; - } - - QVariant valueForColumnInRow(const QString &matchColumn, - const QVariant &matchValue, - const QString &columnName) const { - const int matchCol = columnIndex(matchColumn); - const int valueCol = columnIndex(columnName); - if (matchCol < 0 || valueCol < 0) return QVariant(); - for (int row = 0; row < model_->rowCount(); ++row) { - QStandardItem *matchItem = model_->item(row, matchCol); - if (!matchItem) continue; - const QVariant cellValue = matchItem->data(Qt::UserRole + 1); - if (cellValue.toString() == matchValue.toString()) { - QStandardItem *valueItem = model_->item(row, valueCol); - if (!valueItem) return QVariant(); - return valueItem->data(Qt::UserRole + 1); - } - } - return QVariant(); - } - - bool hasColumn(const QString &name) const { - return columnIndex(name) >= 0; - } - - QVariantMap currentKeyValues() const { - QVariantMap map; - const QModelIndex current = view_->currentIndex(); - if (!current.isValid()) return map; - const int row = current.row(); - for (int col = 0; col < columns_.size(); ++col) { - if (!columns_[col].isPrimaryKey()) continue; - QStandardItem *item = model_->item(row, col); - if (!item) continue; - map.insert(columns_[col].name, item->data(Qt::UserRole + 1)); - } - return map; - } - - bool moveObsAfter(const QString &fromObsId, const QString &toObsId, QString *error = nullptr) { - const int fromRow = findRowByColumnValue("OBSERVATION_ID", fromObsId); - const int toRow = findRowByColumnValue("OBSERVATION_ID", toObsId); - if (fromRow < 0 || toRow < 0) { - if (error) *error = "Target not found in view."; - return false; - } - return moveRowAfter(fromRow, toRow, error); - } - - bool moveGroupAfterObsId(const QString &fromObsId, const QString &toObsId, QString *error = nullptr) { - const int fromRow = findRowByColumnValue("OBSERVATION_ID", fromObsId); - const int toRow = findRowByColumnValue("OBSERVATION_ID", toObsId); - if (fromRow < 0 || toRow < 0) { - if (error) *error = "Target not found in view."; - return false; - } - if (!db_ || !db_->isOpen()) { - if (error) *error = "Not connected"; - return false; - } - ViewState state = captureViewState(); - QString err; - bool ok = false; - if (groupingEnabled_) { - ok = moveGroupAfterRow(fromRow, toRow, &err); - } else { - const int setIdCol = columnIndex("SET_ID"); - int setId = -1; - if (setIdCol >= 0) { - QStandardItem *setItem = model_->item(fromRow, setIdCol); - if (setItem) setId = setItem->data(Qt::UserRole + 1).toInt(); - } - ok = moveSingleAfterRow(fromRow, toRow, setId, &err); - } - if (!ok) { - if (error) *error = err.isEmpty() ? "Move failed." : err; - return false; - } - refreshWithState(state); - emit dataMutated(); - return true; - } - - bool moveGroupToTopObsId(const QString &fromObsId, QString *error = nullptr) { - const int fromRow = findRowByColumnValue("OBSERVATION_ID", fromObsId); - if (fromRow < 0) { - if (error) *error = "Target not found in view."; - return false; - } - if (!db_ || !db_->isOpen()) { - if (error) *error = "Not connected"; - return false; - } - ViewState state = captureViewState(); - QString err; - bool ok = false; - if (groupingEnabled_) { - ok = moveGroupToTopRow(fromRow, &err); - } else { - const int setIdCol = columnIndex("SET_ID"); - int setId = -1; - if (setIdCol >= 0) { - QStandardItem *setItem = model_->item(fromRow, setIdCol); - if (setItem) setId = setItem->data(Qt::UserRole + 1).toInt(); - } - ok = moveSingleToTopRow(fromRow, setId, &err); - } - if (!ok) { - if (error) *error = err.isEmpty() ? "Move failed." : err; - return false; - } - refreshWithState(state); - emit dataMutated(); - return true; - } - - void persistHeaderState() { saveHeaderState(); } - - QList columns() const { return columns_; } - -public slots: - void refresh() { - refreshWithState(captureViewState()); - } - - void addRecord() { - if (!db_ || !db_->isOpen()) return; - if (quickAddEnabled_ && quickAddBuilder_) { - QVariantMap values; - QSet nullColumns; - QString buildError; - if (!quickAddBuilder_(values, nullColumns, &buildError)) { - showWarning(this, "Add failed", buildError.isEmpty() ? "Unable to add target." : buildError); - return; - } - if (normalizer_) { - normalizer_(values, nullColumns); - } - QString error; - ViewState state = captureViewState(); - if (quickAddInsertAtTop_) { - bool okSet = false; - bool okOrder = false; - const int setId = values.value("SET_ID").toInt(&okSet); - const int obsOrder = values.value("OBS_ORDER").toInt(&okOrder); - if (okSet && okOrder && setId > 0 && obsOrder > 0) { - QString shiftErr; - if (!db_->shiftObsOrderForInsert(tableName_, setId, obsOrder, 1, &shiftErr)) { - showWarning(this, "Add failed", - shiftErr.isEmpty() ? "Failed to insert at top." : shiftErr); - return; - } - } - } - if (!insertRecord(values, nullColumns, &error)) { - showWarning(this, "Add failed", error); - return; - } - refreshWithState(state); - if (groupingEnabled_) { - const int setId = values.value("SET_ID").toInt(); - const int obsOrder = values.value("OBS_ORDER").toInt(); - if (setId > 0 && obsOrder > 0) { - const QStringList newObsIds = obsIdsForObsOrderRange(setId, obsOrder, 1); - if (!newObsIds.isEmpty()) { - for (const QString &newObsId : newObsIds) { - manualUngroupObsIds_.insert(newObsId); - manualGroupKeyByObsId_.remove(newObsId); - } - saveGroupingState(); - applyGrouping(); - } - } - } - emit dataMutated(); - return; - } - RecordEditorDialog dialog(tableName_, columns_, QVariantMap(), true, this); - bool inserted = false; - if (dialog.exec() == QDialog::Accepted) { - QVariantMap values = dialog.values(); - QSet nullColumns = dialog.nullColumns(); - if (normalizer_) { - normalizer_(values, nullColumns); - } - QString error; - if (!insertRecord(values, nullColumns, &error)) { - showWarning(this, "Insert failed", error); - } else { - inserted = true; - } - } - refresh(); - if (inserted) emit dataMutated(); - } - - void clearSearch() { - searchEdit_->clear(); - refresh(); - } - -signals: - void selectionChanged(); - void dataMutated(); - -private slots: - void handleItemChanged(QStandardItem *item) { - if (suppressItemChange_) return; - if (!db_ || !db_->isOpen()) return; - if (!item) return; - - const int row = item->row(); - const int col = item->column(); - if (row < 0 || col < 0 || col >= columns_.size()) return; - - ColumnMeta meta = columns_.at(col); - const QVariant oldValue = item->data(Qt::UserRole + 1); - const bool oldIsNull = item->data(Qt::UserRole + 2).toBool(); - - QString text = item->text().trimmed(); - bool newIsNull = false; - QVariant newValue; - if (text.compare("NULL", Qt::CaseInsensitive) == 0 || - (text.isEmpty() && meta.nullable)) { - newIsNull = true; - newValue = QVariant(); - } else { - newIsNull = false; - newValue = text; - } - - if (newIsNull && !meta.nullable) { - showWarning(this, "Update failed", - QString("%1 cannot be NULL").arg(meta.name)); - revertItem(item, oldValue, oldIsNull); - return; - } - if (!newIsNull && text.isEmpty() && !meta.nullable) { - showWarning(this, "Update failed", - QString("%1 is required").arg(meta.name)); - revertItem(item, oldValue, oldIsNull); - return; - } - - QVariantMap values; - QSet nullColumns; - for (int c = 0; c < columns_.size(); ++c) { - ColumnMeta m = columns_.at(c); - QStandardItem *rowItem = model_->item(row, c); - QVariant value = rowItem ? rowItem->data(Qt::EditRole) : QVariant(); - bool isNull = rowItem ? rowItem->data(Qt::UserRole + 2).toBool() : true; - - if (c == col) { - isNull = newIsNull; - value = newValue; - } - - values.insert(m.name, value); - if (isNull) nullColumns.insert(m.name); - } - - NormalizationResult norm; - if (normalizer_) { - norm = normalizer_(values, nullColumns); - } - - QVariantMap keyValues; - for (int c = 0; c < columns_.size(); ++c) { - if (!columns_[c].isPrimaryKey()) continue; - QStandardItem *rowItem = model_->item(row, c); - if (!rowItem) continue; - const QVariant keyValue = rowItem->data(Qt::UserRole + 1); - const bool keyIsNull = rowItem->data(Qt::UserRole + 2).toBool(); - if (!keyValue.isValid() || keyIsNull) { - showWarning(this, "Update failed", - QString("Primary key %1 is NULL").arg(columns_[c].name)); - revertItem(item, oldValue, oldIsNull); - return; - } - keyValues.insert(columns_[c].name, keyValue); - } - - QString error; - if (!db_->updateRecord(tableName_, columns_, values, nullColumns, keyValues, &error)) { - showWarning(this, "Update failed", error); - revertItem(item, oldValue, oldIsNull); - return; - } - - const QVariant normalizedValue = values.value(meta.name); - const bool normalizedIsNull = nullColumns.contains(meta.name); - - suppressItemChange_ = true; - item->setData(normalizedIsNull ? QVariant() : normalizedValue, Qt::EditRole); - item->setData(normalizedIsNull ? QVariant() : normalizedValue, Qt::UserRole + 1); - item->setData(normalizedIsNull, Qt::UserRole + 2); - item->setText(displayForVariant(normalizedValue, normalizedIsNull)); - item->setForeground(QBrush(normalizedIsNull ? view_->palette().color(QPalette::Disabled, QPalette::Text) - : view_->palette().color(QPalette::Text))); - - for (const QString &colName : norm.changedColumns) { - const int colIndex = columnIndex(colName); - if (colIndex < 0 || colIndex >= columns_.size()) continue; - if (colIndex == col) continue; - QStandardItem *targetItem = model_->item(row, colIndex); - if (!targetItem) continue; - const QVariant cellValue = values.value(colName); - const bool cellIsNull = nullColumns.contains(colName); - targetItem->setData(cellIsNull ? QVariant() : cellValue, Qt::EditRole); - targetItem->setData(cellIsNull ? QVariant() : cellValue, Qt::UserRole + 1); - targetItem->setData(cellIsNull, Qt::UserRole + 2); - targetItem->setText(displayForVariant(cellValue, cellIsNull)); - targetItem->setForeground(QBrush(cellIsNull ? view_->palette().color(QPalette::Disabled, QPalette::Text) - : view_->palette().color(QPalette::Text))); - } - suppressItemChange_ = false; - applyGrouping(); - emit dataMutated(); - } - - void handleDragSwap(int sourceRow, int targetRow) { - moveRowAfter(sourceRow, targetRow, nullptr); - } - - void handleDeleteShortcut() { - if (!allowDelete_) return; - const int row = view_->currentIndex().row(); - if (row < 0) return; - deleteRow(row); - } - - bool eventFilter(QObject *obj, QEvent *event) override { - if ((obj == view_->horizontalHeader() || obj == view_->horizontalHeader()->viewport()) && event) { - if (event->type() == QEvent::ContextMenu) { - auto *ctx = static_cast(event); - showColumnHeaderContextMenu(ctx->pos()); - return true; - } - } - return QWidget::eventFilter(obj, event); - } - - void showColumnHeaderContextMenu(const QPoint &pos) { - if (!allowColumnHeaderBulkEdit_) return; - const int col = view_->horizontalHeader()->logicalIndexAt(pos); - if (col < 0 || col >= columns_.size()) return; - if (!isColumnBulkEditable(col)) { - showInfo(this, "Bulk update", "This column cannot be edited."); - return; - } - - QMenu menu(this); - const QString colName = columns_.at(col).name; - QAction *applyAll = menu.addAction(QString("Set %1 For All Targets...").arg(colName)); - QAction *chosen = menu.exec(view_->horizontalHeader()->mapToGlobal(pos)); - if (chosen != applyAll) return; - showColumnHeaderBulkEditDialog(col); - } - - void showColumnHeaderBulkEditDialog(int col) { - if (!allowColumnHeaderBulkEdit_) return; - if (!db_ || !db_->isOpen()) { - showWarning(this, "Bulk update", "Not connected"); - return; - } - if (col < 0 || col >= columns_.size()) return; - if (!isColumnBulkEditable(col)) { - showInfo(this, "Bulk update", "This column cannot be edited."); - return; - } - - QString defaultValue; - const QModelIndex current = view_->currentIndex(); - if (current.isValid()) { - QStandardItem *item = model_->item(current.row(), col); - if (item) { - const bool isNull = item->data(Qt::UserRole + 2).toBool(); - const QVariant val = item->data(Qt::UserRole + 1); - defaultValue = displayForVariant(val, isNull); - } - } - - bool ok = false; - const QString column = columns_.at(col).name; - const QString value = QInputDialog::getText( - this, "Bulk update", - QString("Set %1 for all targets:").arg(column), - QLineEdit::Normal, defaultValue, &ok); - if (!ok) return; - - const QStringList obsIds = obsIdsInView(); - if (obsIds.isEmpty()) { - showInfo(this, "Bulk update", "No targets found."); - return; - } - - QStringList errors; - if (!updateColumnForObsIds(obsIds, column, value, &errors)) { - if (!errors.isEmpty()) { - showWarning(this, "Bulk update", errors.join("\n")); - } else { - showWarning(this, "Bulk update", "Update failed."); - } - } - } - - void handleCellClick(const QModelIndex &index, const QPoint &pos) { - if (!groupingEnabled_) return; - if (!index.isValid()) return; - const int nameCol = columnIndex("NAME"); - if (nameCol < 0 || index.column() != nameCol) return; - const int row = index.row(); - if (!isGroupHeaderRow(row)) return; - QRect cellRect = view_->visualRect(index); - const int iconSize = view_->style()->pixelMetric(QStyle::PM_SmallIconSize); - QRect iconRect(cellRect.left() + 4, - cellRect.center().y() - iconSize / 2, - iconSize, iconSize); - if (!iconRect.contains(pos)) return; - const QString key = groupKeyForRow(row); - if (key.isEmpty()) return; - toggleGroup(key); - } - - void moveRowToPositionDialog(int row) { - if (!allowReorder_) return; - if (!searchEdit_->text().trimmed().isEmpty()) { - showInfo(this, "Reorder disabled", "Clear the search filter before reordering."); - return; - } - if (row < 0 || row >= model_->rowCount()) return; - if (!db_ || !db_->isOpen()) { - showWarning(this, "Reorder failed", "Not connected"); - return; - } - - const int obsIdCol = columnIndex("OBSERVATION_ID"); - if (obsIdCol < 0) return; - const QString fromObsId = model_->item(row, obsIdCol)->data(Qt::UserRole + 1).toString(); - if (fromObsId.isEmpty()) return; - - const bool groupMove = groupingEnabled_ && isGroupHeaderRow(row) && - !expandedGroups_.contains(groupKeyForRow(row)); - - int maxPos = 0; - int currentPos = 1; - if (groupMove) { - QVector> order; - order.reserve(groupRowsByKey_.size()); - const int obsOrderCol = columnIndex("OBS_ORDER"); - if (obsIdCol < 0 || obsOrderCol < 0) return; - for (auto it = groupRowsByKey_.begin(); it != groupRowsByKey_.end(); ++it) { - int minOrder = std::numeric_limits::max(); - const int headerRow = groupHeaderRowByKey_.value(it.key(), -1); - for (int memberRow : it.value()) { - QStandardItem *orderItem = model_->item(memberRow, obsOrderCol); - if (!orderItem) continue; - const int orderVal = orderItem->data(Qt::UserRole + 1).toInt(); - if (memberRow == headerRow) { - minOrder = orderVal; - } else if (minOrder == std::numeric_limits::max()) { - minOrder = orderVal; - } else if (headerRow < 0) { - minOrder = std::min(minOrder, orderVal); - } - } - if (minOrder == std::numeric_limits::max()) minOrder = 0; - order.append({it.key(), minOrder}); - } - std::sort(order.begin(), order.end(), - [](const auto &a, const auto &b) { return a.second < b.second; }); - maxPos = order.size(); - const QString fromKey = groupKeyForRow(row); - for (int i = 0; i < order.size(); ++i) { - if (order[i].first == fromKey) { - currentPos = i + 1; - break; - } - } - } else { - const int obsIdCol = columnIndex("OBSERVATION_ID"); - const int obsOrderCol = columnIndex("OBS_ORDER"); - const int setIdCol = columnIndex("SET_ID"); - if (obsIdCol < 0 || obsOrderCol < 0) return; - QList infos; - infos.reserve(model_->rowCount()); - int setId = -1; - if (setIdCol >= 0) { - QStandardItem *setItem = model_->item(row, setIdCol); - if (setItem) setId = setItem->data(Qt::UserRole + 1).toInt(); - } - for (int r = 0; r < model_->rowCount(); ++r) { - QStandardItem *obsItem = model_->item(r, obsIdCol); - QStandardItem *orderItem = model_->item(r, obsOrderCol); - if (!obsItem || !orderItem) continue; - if (setIdCol >= 0 && setId >= 0) { - QStandardItem *setItem = model_->item(r, setIdCol); - if (!setItem || setItem->data(Qt::UserRole + 1).toInt() != setId) continue; - } - RowInfo info; - info.row = r; - info.obsId = obsItem->data(Qt::UserRole + 1).toString(); - info.obsOrder = orderItem->data(Qt::UserRole + 1).toInt(); - infos.append(info); - } - std::sort(infos.begin(), infos.end(), - [](const RowInfo &a, const RowInfo &b) { return a.obsOrder < b.obsOrder; }); - maxPos = infos.size(); - const QString fromObsId = model_->item(row, obsIdCol)->data(Qt::UserRole + 1).toString(); - for (int i = 0; i < infos.size(); ++i) { - if (infos[i].obsId == fromObsId) { - currentPos = i + 1; - break; - } - } - } - - if (maxPos <= 0) return; - if (moveToDialog_) { - moveToDialog_->close(); - moveToDialog_.clear(); - } - - QDialog *dialog = new QDialog(this); - moveToDialog_ = dialog; - dialog->setAttribute(Qt::WA_DeleteOnClose); - dialog->setWindowModality(Qt::NonModal); - dialog->setModal(false); - dialog->setWindowTitle("Move to Position"); - QVBoxLayout *layout = new QVBoxLayout(dialog); - QFormLayout *form = new QFormLayout(); - QSpinBox *posSpin = new QSpinBox(dialog); - posSpin->setRange(1, maxPos); - posSpin->setValue(currentPos); - form->addRow(QString("Position (1-%1):").arg(maxPos), posSpin); - layout->addLayout(form); - QDialogButtonBox *buttons = - new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, dialog); - connect(buttons, &QDialogButtonBox::accepted, dialog, &QDialog::accept); - connect(buttons, &QDialogButtonBox::rejected, dialog, &QDialog::reject); - layout->addWidget(buttons); - - connect(dialog, &QDialog::accepted, this, [this, fromObsId, groupMove, maxPos, currentPos]() { - if (!moveToDialog_) return; - const QList spins = moveToDialog_->findChildren(); - if (spins.isEmpty()) return; - int newPos = spins.first()->value(); - if (newPos < 1) newPos = 1; - if (newPos > maxPos) newPos = maxPos; - if (newPos == currentPos) return; - - const int fromRow = findRowByColumnValue("OBSERVATION_ID", fromObsId); - if (fromRow < 0) return; - - QString err; - ViewState state = captureViewState(); - bool moved = false; - if (groupMove) { - moved = moveGroupToPositionRow(fromRow, newPos, &err); - } else { - const int setIdCol = columnIndex("SET_ID"); - int setId = -1; - if (setIdCol >= 0) { - QStandardItem *setItem = model_->item(fromRow, setIdCol); - if (setItem) setId = setItem->data(Qt::UserRole + 1).toInt(); - } - moved = moveSingleToPositionRow(fromRow, newPos, setId, &err); - } - if (!moved) { - if (!err.isEmpty()) showWarning(this, "Reorder failed", err); - return; - } - refreshWithState(state); - emit dataMutated(); - }); - connect(dialog, &QDialog::finished, this, [this](int) { moveToDialog_.clear(); }); - dialog->show(); - } - - void showContextMenu(const QPoint &pos) { - const QModelIndex index = view_->indexAt(pos); - if (!index.isValid()) return; - showContextMenuAtRow(index.row(), view_->viewport()->mapToGlobal(pos)); - } - -private: - void showContextMenuAtRow(int row, const QPoint &globalPos) { - if (row < 0 || row >= model_->rowCount()) return; - if (!allowDelete_ && !allowReorder_) return; - const bool searchActive = !searchEdit_->text().trimmed().isEmpty(); - - QMenu menu(this); - QMenu *seqMenu = nullptr; - QAction *deleteAction = nullptr; - QAction *duplicateAction = nullptr; - QAction *moveUp = nullptr; - QAction *moveDown = nullptr; - QAction *moveTop = nullptr; - QAction *moveBottom = nullptr; - QAction *moveTo = nullptr; - QList seqActions; - QAction *ungroupAction = nullptr; - QAction *regroupAction = nullptr; - const int obsIdCol = columnIndex("OBSERVATION_ID"); - const int nameCol = columnIndex("NAME"); - const int obsOrderCol = columnIndex("OBS_ORDER"); - QString obsId; - if (obsIdCol >= 0) { - QStandardItem *obsItem = model_->item(row, obsIdCol); - if (obsItem) obsId = obsItem->data(Qt::UserRole + 1).toString(); - } - const bool canUngroup = groupingEnabled_ && !obsId.isEmpty(); - const bool isUngrouped = canUngroup && manualUngroupObsIds_.contains(obsId); - if (allowReorder_ && !searchActive) { - moveUp = menu.addAction("Move Up"); - moveDown = menu.addAction("Move Down"); - moveTop = menu.addAction("Move to Top"); - moveBottom = menu.addAction("Move to Bottom"); - moveTo = menu.addAction("Move to Position..."); - menu.addSeparator(); - } - if (allowReorder_) { - duplicateAction = menu.addAction("Duplicate"); - } - if (groupingEnabled_ && !obsId.isEmpty()) { - const QString groupKey = groupKeyForRow(row); - const QList members = groupRowsByKey_.value(groupKey); - if (!members.isEmpty()) { - seqMenu = menu.addMenu("Use For Sequencer"); - QList orderedMembers = members; - if (obsOrderCol >= 0) { - std::sort(orderedMembers.begin(), orderedMembers.end(), [&](int a, int b) { - QStandardItem *orderA = model_->item(a, obsOrderCol); - QStandardItem *orderB = model_->item(b, obsOrderCol); - const int oa = orderA ? orderA->data(Qt::UserRole + 1).toInt() : 0; - const int ob = orderB ? orderB->data(Qt::UserRole + 1).toInt() : 0; - return oa < ob; - }); - } - const QString headerObsId = headerObsIdForGroupKey(groupKey); - const QString selectedObsId = selectedObsIdByHeader_.value(headerObsId); - for (int memberRow : orderedMembers) { - const QString memberObsId = obsIdForRow(memberRow); - if (memberObsId.isEmpty()) continue; - QString label = memberObsId; - if (nameCol >= 0) { - QStandardItem *nameItem = model_->item(memberRow, nameCol); - const QString rawName = nameItem ? nameItem->data(Qt::UserRole + 1).toString() : QString(); - if (!rawName.isEmpty()) label = rawName; - } - QAction *act = seqMenu->addAction(label); - act->setData(memberObsId); - act->setCheckable(true); - if (!selectedObsId.isEmpty() && memberObsId == selectedObsId) { - act->setChecked(true); - } - seqActions.append(act); - } - menu.addSeparator(); - } - } - if (canUngroup) { - if (isUngrouped) { - regroupAction = menu.addAction("Restore Grouping"); - } else { - ungroupAction = menu.addAction("Remove From Group"); - } - menu.addSeparator(); - } - if (allowDelete_) { - deleteAction = menu.addAction("Delete"); - } - QAction *chosen = menu.exec(globalPos); - if (!chosen) return; - - if (seqMenu && seqActions.contains(chosen)) { - const QString selectedObsId = chosen->data().toString(); - const QString key = groupKeyForRow(row); - setGroupSequencerSelection(key, selectedObsId); - return; - } - - if (chosen == ungroupAction) { - if (!obsId.isEmpty()) { - const QString key = groupKeyForRow(row); - QList members = groupRowsByKey_.value(key); - const int obsOrderCol = columnIndex("OBS_ORDER"); - int lastRow = row; - int bestOrder = std::numeric_limits::min(); - if (members.size() > 1 && obsOrderCol >= 0) { - for (int memberRow : members) { - if (memberRow == row) continue; - QStandardItem *orderItem = model_->item(memberRow, obsOrderCol); - if (!orderItem) continue; - const int orderVal = orderItem->data(Qt::UserRole + 1).toInt(); - if (orderVal >= bestOrder) { - bestOrder = orderVal; - lastRow = memberRow; - } - } - } - manualUngroupObsIds_.insert(obsId); - manualGroupKeyByObsId_.remove(obsId); - saveGroupingState(); - if (members.size() > 1 && lastRow != row) { - QString err; - if (!moveSingleAfterRowWithRefresh(row, lastRow, &err)) { - if (!err.isEmpty()) showWarning(this, "Reorder failed", err); - applyGrouping(); - } - } else { - applyGrouping(); - } - } - return; - } - if (chosen == regroupAction) { - if (!obsId.isEmpty()) { - manualUngroupObsIds_.remove(obsId); - manualGroupKeyByObsId_.remove(obsId); - saveGroupingState(); - applyGrouping(); - } - return; - } - if (chosen == deleteAction) { - deleteRow(row); - } else if (chosen == duplicateAction) { - duplicateRow(row); - } else if (chosen == moveUp) { - const int target = previousVisibleRow(row); - if (target >= 0) moveRowAfter(row, target, nullptr); - } else if (chosen == moveDown) { - const int target = nextVisibleRow(row); - if (target >= 0) moveRowAfter(row, target, nullptr); - } else if (chosen == moveTop) { - if (!obsId.isEmpty()) { - QString err; - if (!moveGroupToTopObsId(obsId, &err)) { - showWarning(this, "Reorder failed", err.isEmpty() ? "Move to top failed." : err); - } - } else { - const int target = firstVisibleRow(); - if (target >= 0) moveRowAfter(row, target, nullptr); - } - } else if (chosen == moveBottom) { - const int target = lastVisibleRow(); - if (target >= 0) moveRowAfter(row, target, nullptr); - } else if (chosen == moveTo) { - moveRowToPositionDialog(row); - } - } - struct SwapInfo { - bool valid = false; - QVariant obsId; - QVariant obsOrder; - }; - - struct RowInfo { - QString obsId; - int obsOrder = 0; - int row = -1; - int setId = -1; - QString groupKey; - }; - - struct ViewState { - int vScroll = 0; - int hScroll = 0; - QVariantMap keyValues; - int sortColumn = -1; - Qt::SortOrder sortOrder = Qt::AscendingOrder; - }; - - ViewState captureViewState() const { - ViewState state; - state.vScroll = view_->verticalScrollBar()->value(); - state.hScroll = view_->horizontalScrollBar()->value(); - state.keyValues = currentKeyValues(); - if (sortingEnabled_) { - state.sortColumn = view_->horizontalHeader()->sortIndicatorSection(); - state.sortOrder = view_->horizontalHeader()->sortIndicatorOrder(); - } else { - state.sortColumn = -1; - } - return state; - } - - void restoreViewState(const ViewState &state) { - view_->setUpdatesEnabled(false); - if (sortingEnabled_ && state.sortColumn >= 0) { - model_->sort(state.sortColumn, state.sortOrder); - } - if (!state.keyValues.isEmpty()) { - const int row = findRowByKey(state.keyValues); - if (row >= 0) { - const QModelIndex idx = model_->index(row, 0); - view_->selectionModel()->setCurrentIndex( - idx, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); - } - } - view_->horizontalScrollBar()->setValue(state.hScroll); - view_->verticalScrollBar()->setValue(state.vScroll); - view_->setUpdatesEnabled(true); - - QTimer::singleShot(0, this, [this, state]() { - if (!view_) return; - view_->horizontalScrollBar()->setValue(state.hScroll); - view_->verticalScrollBar()->setValue(state.vScroll); - }); - } - - void refreshWithState(const ViewState &state) { - if (!db_ || !db_->isOpen() || tableName_.isEmpty()) { - statusLabel_->setText("Not connected"); - return; - } - QString error; - if (!db_->loadColumns(tableName_, columns_, &error)) { - statusLabel_->setText(error.isEmpty() ? "Failed to read columns" : error); - return; - } - QList> rows; - const QString searchValue = searchEdit_->text().trimmed(); - if (!db_->fetchRows(tableName_, columns_, - fixedFilterColumn_, fixedFilterValue_, - searchColumn_, searchValue, - orderByColumn_, - rows, &error)) { - statusLabel_->setText(error.isEmpty() ? "Failed to read rows" : error); - return; - } - - suppressItemChange_ = true; - model_->clear(); - model_->setColumnCount(columns_.size()); - QStringList headers; - for (const ColumnMeta &meta : columns_) { - headers << meta.name; - } - model_->setHorizontalHeaderLabels(headers); - if (headerSaveTimer_ && headerSaveTimer_->isActive()) { - headerSaveTimer_->stop(); - saveHeaderState(); - } - const bool restoredHeader = restoreHeaderState(); - if (!restoredHeader) { - applyColumnOrderRules(); - } else if (headerRulesPending_) { - applyColumnOrderRules(); - headerRulesPending_ = false; - saveHeaderState(); - } - applyHiddenColumns(); - - const QColor textColor = view_->palette().color(QPalette::Text); - const QColor nullColor = view_->palette().color(QPalette::Disabled, QPalette::Text); - - for (const QList &rowValues : rows) { - QList items; - items.reserve(columns_.size()); - for (int col = 0; col < columns_.size(); ++col) { - QVariant value; - if (col < rowValues.size()) { - value = rowValues.at(col); - } - const bool isNull = !value.isValid() || value.isNull(); - QStandardItem *item = new QStandardItem(displayForVariant(value, isNull)); - const QString colName = columns_.at(col).name.toUpper(); - if (colName == "EXPTIME") { - item->setToolTip("Format: SET or SNR . Example: SET 600"); - } else if (colName == "SLITWIDTH") { - item->setToolTip("Format: SET , SNR , LOSS , RES , AUTO"); - } else if (colName == "SLITANGLE") { - item->setToolTip("Format: numeric degrees or PA"); - } else if (colName == "MAGFILTER") { - item->setToolTip("U,B,V,R,I,J,K, or match. G is mapped to match."); - } else if (colName == "CHANNEL") { - item->setToolTip("U, G, R, or I"); - } else if (colName == "WRANGE_LOW" || colName == "WRANGE_HIGH") { - item->setToolTip("Wavelength range in nm; defaults around channel center"); - } - item->setEditable(true); - if (!isNull) { - item->setData(value, Qt::EditRole); - } else { - item->setData(QVariant(), Qt::EditRole); - } - item->setData(isNull ? QVariant() : value, Qt::UserRole + 1); - item->setData(isNull, Qt::UserRole + 2); - item->setForeground(QBrush(isNull ? nullColor : textColor)); - items.push_back(item); - } - model_->appendRow(items); - } - - suppressItemChange_ = false; - restoreViewState(state); - if (!restoredHeader) { - saveHeaderState(); - } - const QString timestamp = QDateTime::currentDateTime().toString("yyyy-MM-dd HH:mm:ss"); - statusLabel_->setText(QString("Last refresh: %1").arg(timestamp)); - - if (!groupingStateLoaded_) { - loadGroupingState(); - groupingStateLoaded_ = true; - } - applyGrouping(); - } - - int findRowByKey(const QVariantMap &keyValues) const { - if (keyValues.isEmpty()) return -1; - for (int row = 0; row < model_->rowCount(); ++row) { - bool match = true; - for (int col = 0; col < columns_.size(); ++col) { - if (!columns_[col].isPrimaryKey()) continue; - QStandardItem *item = model_->item(row, col); - if (!item) { match = false; break; } - QVariant val = item->data(Qt::UserRole + 1); - if (val.toString() != keyValues.value(columns_[col].name).toString()) { - match = false; - break; - } - } - if (match) return row; - } - return -1; - } - - int findRowByColumnValue(const QString &columnName, const QVariant &value) const { - const int col = columnIndex(columnName); - if (col < 0) return -1; - for (int row = 0; row < model_->rowCount(); ++row) { - QStandardItem *item = model_->item(row, col); - if (!item) continue; - const QVariant cellValue = item->data(Qt::UserRole + 1); - if (cellValue.toString() == value.toString()) { - return row; - } - } - return -1; - } - - SwapInfo swapInfoForRow(int row) const { - SwapInfo info; - if (row < 0 || row >= model_->rowCount()) return info; - const int obsIdCol = columnIndex("OBSERVATION_ID"); - const int obsOrderCol = columnIndex("OBS_ORDER"); - if (obsIdCol < 0 || obsOrderCol < 0) return info; - QStandardItem *obsItem = model_->item(row, obsIdCol); - QStandardItem *orderItem = model_->item(row, obsOrderCol); - if (!obsItem || !orderItem) return info; - QVariant obsId = obsItem->data(Qt::UserRole + 1); - QVariant obsOrder = orderItem->data(Qt::UserRole + 1); - if (!obsId.isValid() || !obsOrder.isValid()) return info; - info.valid = true; - info.obsId = obsId; - info.obsOrder = obsOrder; - return info; - } - - bool isGroupHeaderRow(int row) const { - if (!groupingEnabled_) return false; - if (!groupKeyByRow_.contains(row)) return false; - const QString key = groupKeyByRow_.value(row); - const int headerRow = groupHeaderRowByKey_.value(key, -1); - return headerRow == row; - } - - QString groupKeyForRow(int row) const { - return groupKeyByRow_.value(row); - } - - QString obsIdForRow(int row) const { - const int obsIdCol = columnIndex("OBSERVATION_ID"); - if (obsIdCol < 0 || row < 0 || row >= model_->rowCount()) return QString(); - QStandardItem *item = model_->item(row, obsIdCol); - if (!item) return QString(); - return item->data(Qt::UserRole + 1).toString(); - } - - QString headerObsIdForGroupKey(const QString &groupKey) const { - if (groupKey.isEmpty()) return QString(); - const int headerRow = groupHeaderRowByKey_.value(groupKey, -1); - QString headerObsId = obsIdForRow(headerRow); - if (!headerObsId.isEmpty()) return headerObsId; - const QList members = groupRowsByKey_.value(groupKey); - if (!members.isEmpty()) { - headerObsId = obsIdForRow(members.first()); - } - return headerObsId; - } - - void setGroupSequencerSelection(const QString &groupKey, const QString &selectedObsId) { - if (groupKey.isEmpty() || selectedObsId.isEmpty()) return; - const QList members = groupRowsByKey_.value(groupKey); - bool inGroup = false; - for (int row : members) { - if (obsIdForRow(row) == selectedObsId) { - inGroup = true; - break; - } - } - if (!inGroup) return; - const QString headerObsId = headerObsIdForGroupKey(groupKey); - if (headerObsId.isEmpty()) return; - selectedObsIdByHeader_[headerObsId] = selectedObsId; - saveGroupingState(); - applyGrouping(); - } - - void toggleGroup(const QString &key) { - if (key.isEmpty()) return; - if (expandedGroups_.contains(key)) { - expandedGroups_.remove(key); - } else { - expandedGroups_.insert(key); - } - applyGrouping(); - } - - void applyGrouping() { - if (!groupingEnabled_) { - for (int row = 0; row < model_->rowCount(); ++row) { - view_->setRowHidden(row, false); - } - return; - } - - const bool prevSuppress = suppressItemChange_; - suppressItemChange_ = true; - - const int iconSize = view_->style()->pixelMetric(QStyle::PM_SmallIconSize); - const QColor iconColor(90, 160, 255); - auto makeArrowIcon = [&](Qt::ArrowType arrow) { - QPixmap pix(iconSize, iconSize); - pix.fill(Qt::transparent); - QPainter p(&pix); - p.setRenderHint(QPainter::Antialiasing, true); - p.setPen(Qt::NoPen); - p.setBrush(iconColor); - QPolygon poly; - if (arrow == Qt::DownArrow) { - poly << QPoint(iconSize / 2, iconSize - 2) - << QPoint(2, 2) - << QPoint(iconSize - 2, 2); - } else { - poly << QPoint(iconSize - 2, iconSize / 2) - << QPoint(2, 2) - << QPoint(2, iconSize - 2); - } - p.drawPolygon(poly); - return QIcon(pix); - }; - QIcon collapsedIcon = makeArrowIcon(Qt::RightArrow); - QIcon expandedIcon = makeArrowIcon(Qt::DownArrow); - - const int obsIdCol = columnIndex("OBSERVATION_ID"); - const int nameCol = columnIndex("NAME"); - if (obsIdCol < 0 || nameCol < 0) return; - - const int raCol = columnIndex("RA"); - const int decCol = columnIndex("DECL"); - const int offsetRaCol = columnIndex("OFFSET_RA"); - const int offsetDecCol = columnIndex("OFFSET_DEC"); - const int draCol = columnIndex("DRA"); - const int ddecCol = columnIndex("DDEC"); - - struct RowInfo { - int row = -1; - QString obsId; - QString name; - QString groupKey; - bool isScience = false; - int obsOrder = 0; - bool coordOk = false; - double raDeg = 0.0; - double decDeg = 0.0; - }; - - auto assignGroupKeys = [&](QVector &rows) { - struct GroupCenter { - QString key; - double ra = 0.0; - double dec = 0.0; - }; - QVector centers; - auto makeKey = [](double raDeg, double decDeg) { - return QString("%1:%2") - .arg(QString::number(raDeg, 'f', 6)) - .arg(QString::number(decDeg, 'f', 6)); - }; - for (const RowInfo &info : rows) { - if (manualUngroupObsIds_.contains(info.obsId)) { - continue; - } - if (info.coordOk && info.isScience) { - centers.push_back({makeKey(info.raDeg, info.decDeg), info.raDeg, info.decDeg}); - } - } - const bool hasScienceCenters = !centers.isEmpty(); - for (RowInfo &info : rows) { - if (manualUngroupObsIds_.contains(info.obsId)) { - info.groupKey = QString("UNGROUP:%1").arg(info.obsId); - continue; - } - const QString manualKey = manualGroupKeyByObsId_.value(info.obsId); - if (!manualKey.isEmpty()) { - info.groupKey = manualKey; - continue; - } - if (!info.coordOk) { - info.groupKey = QString("OBS:%1").arg(info.obsId); - continue; - } - if (!hasScienceCenters) { - info.groupKey = QString("OBS:%1").arg(info.obsId); - continue; - } - double bestSep = 1e12; - int bestIdx = -1; - for (int i = 0; i < centers.size(); ++i) { - const double sep = angularSeparationArcsec(info.raDeg, info.decDeg, - centers[i].ra, centers[i].dec); - if (sep < bestSep) { - bestSep = sep; - bestIdx = i; - } - } - if (bestIdx >= 0 && bestSep <= kGroupCoordTolArcsec) { - info.groupKey = centers[bestIdx].key; - } else { - info.groupKey = QString("OBS:%1").arg(info.obsId); - } - } - }; - - auto collectRows = [&]() { - QVector rows; - rows.reserve(model_->rowCount()); - for (int row = 0; row < model_->rowCount(); ++row) { - QStandardItem *obsItem = model_->item(row, obsIdCol); - QStandardItem *nameItem = model_->item(row, nameCol); - if (!obsItem || !nameItem) continue; - const QString obsId = obsItem->data(Qt::UserRole + 1).toString(); - const QString name = nameItem->data(Qt::UserRole + 1).toString(); - QVariantMap values; - if (raCol >= 0) values.insert("RA", model_->item(row, raCol)->data(Qt::UserRole + 1)); - if (decCol >= 0) values.insert("DECL", model_->item(row, decCol)->data(Qt::UserRole + 1)); - if (offsetRaCol >= 0) values.insert("OFFSET_RA", model_->item(row, offsetRaCol)->data(Qt::UserRole + 1)); - if (offsetDecCol >= 0) values.insert("OFFSET_DEC", model_->item(row, offsetDecCol)->data(Qt::UserRole + 1)); - if (draCol >= 0) values.insert("DRA", model_->item(row, draCol)->data(Qt::UserRole + 1)); - if (ddecCol >= 0) values.insert("DDEC", model_->item(row, ddecCol)->data(Qt::UserRole + 1)); - - bool hasRa = false; - bool hasDec = false; - const double offsetRa = offsetArcsecFromValues(values, {"OFFSET_RA", "DRA"}, &hasRa); - const double offsetDec = offsetArcsecFromValues(values, {"OFFSET_DEC", "DDEC"}, &hasDec); - const bool isScience = (!hasRa && !hasDec) || - (std::abs(offsetRa) <= kOffsetZeroTolArcsec && - std::abs(offsetDec) <= kOffsetZeroTolArcsec); - - int obsOrder = 0; - const int obsOrderCol = columnIndex("OBS_ORDER"); - if (obsOrderCol >= 0) { - QStandardItem *orderItem = model_->item(row, obsOrderCol); - if (orderItem) obsOrder = orderItem->data(Qt::UserRole + 1).toInt(); - } - - double raDeg = 0.0; - double decDeg = 0.0; - const bool coordOk = computeScienceCoordDegreesProjected(values, &raDeg, &decDeg); - RowInfo info; - info.row = row; - info.obsId = obsId; - info.name = name; - info.groupKey = QString(); - info.isScience = isScience; - info.obsOrder = obsOrder; - info.coordOk = coordOk; - info.raDeg = raDeg; - info.decDeg = decDeg; - rows.push_back(info); - } - assignGroupKeys(rows); - return rows; - }; - - auto buildMaps = [&](const QVector &rows, - QHash> &rowsByKey, - QHash &headerByKey, - QHash &keyByRow) { - rowsByKey.clear(); - headerByKey.clear(); - keyByRow.clear(); - for (const RowInfo &info : rows) { - rowsByKey[info.groupKey].append(info.row); - if (info.isScience && !headerByKey.contains(info.groupKey)) { - headerByKey[info.groupKey] = info.row; - } - keyByRow.insert(info.row, info.groupKey); - } - for (auto it = rowsByKey.begin(); it != rowsByKey.end(); ++it) { - if (!headerByKey.contains(it.key()) && !it.value().isEmpty()) { - headerByKey[it.key()] = it.value().first(); - } - } - }; - - QVector rows = collectRows(); - QHash> rowsByKey; - QHash headerByKey; - QHash keyByRow; - buildMaps(rows, rowsByKey, headerByKey, keyByRow); - - QVector currentOrder; - currentOrder.reserve(model_->rowCount()); - for (int row = 0; row < model_->rowCount(); ++row) { - QStandardItem *obsItem = model_->item(row, obsIdCol); - if (!obsItem) continue; - currentOrder << obsItem->data(Qt::UserRole + 1).toString(); - } - - struct GroupOrder { - QString key; - int headerOrder = 0; - QVector members; - QString headerObsId; - }; - - QHash> rowsByGroup; - for (const RowInfo &info : rows) { - rowsByGroup[info.groupKey].append(info); - } - - QVector groupOrder; - groupOrder.reserve(rowsByGroup.size()); - for (auto it = rowsByGroup.begin(); it != rowsByGroup.end(); ++it) { - QVector members = it.value(); - std::sort(members.begin(), members.end(), - [](const RowInfo &a, const RowInfo &b) { return a.obsOrder < b.obsOrder; }); - QString headerObsId; - int headerOrder = members.isEmpty() ? 0 : members.first().obsOrder; - for (const RowInfo &info : members) { - if (info.isScience) { - headerObsId = info.obsId; - headerOrder = info.obsOrder; - break; - } - } - if (headerObsId.isEmpty() && !members.isEmpty()) { - headerObsId = members.first().obsId; - } - groupOrder.push_back({it.key(), headerOrder, members, headerObsId}); - } - - std::sort(groupOrder.begin(), groupOrder.end(), - [](const GroupOrder &a, const GroupOrder &b) { return a.headerOrder < b.headerOrder; }); - - QVector desiredOrder; - for (const GroupOrder &group : groupOrder) { - if (!group.headerObsId.isEmpty()) { - desiredOrder.append(group.headerObsId); - } - for (const RowInfo &member : group.members) { - if (member.obsId == group.headerObsId) continue; - desiredOrder.append(member.obsId); - } - } - - if (desiredOrder.size() == currentOrder.size() && desiredOrder != currentOrder) { - QVariantMap selectedKeys = currentKeyValues(); - const int vScroll = view_->verticalScrollBar()->value(); - const int hScroll = view_->horizontalScrollBar()->value(); - - QHash obsIdByRow; - obsIdByRow.reserve(model_->rowCount()); - for (int row = 0; row < model_->rowCount(); ++row) { - obsIdByRow.insert(row, currentOrder.value(row)); - } - QHash> itemsByObsId; - for (int row = model_->rowCount() - 1; row >= 0; --row) { - const QString obsId = obsIdByRow.value(row); - itemsByObsId.insert(obsId, model_->takeRow(row)); - } - for (const QString &obsId : desiredOrder) { - if (!itemsByObsId.contains(obsId)) continue; - model_->insertRow(model_->rowCount(), itemsByObsId.take(obsId)); - } - for (auto it = itemsByObsId.begin(); it != itemsByObsId.end(); ++it) { - model_->insertRow(model_->rowCount(), it.value()); - } - - if (!selectedKeys.isEmpty()) { - const int selRow = findRowByKey(selectedKeys); - if (selRow >= 0) { - const QModelIndex idx = model_->index(selRow, 0); - view_->selectionModel()->setCurrentIndex( - idx, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); - } - } - view_->verticalScrollBar()->setValue(vScroll); - view_->horizontalScrollBar()->setValue(hScroll); - } - - rows = collectRows(); - buildMaps(rows, groupRowsByKey_, groupHeaderRowByKey_, groupKeyByRow_); - - rowsByGroup.clear(); - rowsByGroup.reserve(groupRowsByKey_.size()); - for (const RowInfo &info : rows) { - rowsByGroup[info.groupKey].append(info); - } - - QHash selectedByKey; - QSet validHeaderObsIds; - bool selectionChanged = false; - for (auto it = rowsByGroup.begin(); it != rowsByGroup.end(); ++it) { - QVector members = it.value(); - if (members.isEmpty()) continue; - std::sort(members.begin(), members.end(), - [](const RowInfo &a, const RowInfo &b) { return a.obsOrder < b.obsOrder; }); - - QString headerObsId; - const int headerRow = groupHeaderRowByKey_.value(it.key(), -1); - if (headerRow >= 0) { - QStandardItem *headerItem = model_->item(headerRow, obsIdCol); - if (headerItem) headerObsId = headerItem->data(Qt::UserRole + 1).toString(); - } - if (headerObsId.isEmpty()) { - headerObsId = members.first().obsId; - } - if (headerObsId.isEmpty()) continue; - validHeaderObsIds.insert(headerObsId); - - QString selectedObsId = selectedObsIdByHeader_.value(headerObsId); - bool selectedValid = false; - for (const RowInfo &member : members) { - if (member.obsId == selectedObsId) { - selectedValid = true; - break; - } - } - if (!selectedValid) { - QString defaultObsId; - for (const RowInfo &member : members) { - if (!member.isScience) { - defaultObsId = member.obsId; - break; - } - } - if (defaultObsId.isEmpty()) defaultObsId = headerObsId; - selectedObsId = defaultObsId; - selectedObsIdByHeader_[headerObsId] = selectedObsId; - selectionChanged = true; - } - selectedByKey.insert(it.key(), selectedObsId); - } - - for (auto it = selectedObsIdByHeader_.begin(); it != selectedObsIdByHeader_.end(); ) { - if (!validHeaderObsIds.contains(it.key())) { - it = selectedObsIdByHeader_.erase(it); - selectionChanged = true; - } else { - ++it; - } - } - if (selectionChanged) { - saveGroupingState(); - } - - const int stateCol = columnIndex("STATE"); - if (stateCol >= 0 && db_ && db_->isOpen() && !rowsByGroup.isEmpty()) { - QList keyValuesList; - QList stateValues; - struct StateUpdate { - int row = -1; - QString value; - }; - QVector stateUpdates; - - auto shouldOverrideState = [](const QString &state) { - const QString s = state.trimmed().toLower(); - return s.isEmpty() || s == "pending" || s == "unassigned"; - }; - - for (auto it = rowsByGroup.begin(); it != rowsByGroup.end(); ++it) { - const QString selectedObsId = selectedByKey.value(it.key()); - if (selectedObsId.isEmpty()) continue; - for (const RowInfo &member : it.value()) { - QStandardItem *stateItem = model_->item(member.row, stateCol); - if (!stateItem) continue; - const QString currentState = stateItem->data(Qt::UserRole + 1).toString(); - if (!shouldOverrideState(currentState)) continue; - const bool isSelected = (member.obsId == selectedObsId); - const QString desiredState = isSelected ? kDefaultTargetState : QString("unassigned"); - if (currentState.compare(desiredState, Qt::CaseInsensitive) == 0) continue; - - QVariantMap keyValues = keyValuesForRow(member.row); - if (keyValues.isEmpty()) continue; - keyValuesList.append(keyValues); - stateValues.append(desiredState); - stateUpdates.push_back({member.row, desiredState}); - } - } - - if (!keyValuesList.isEmpty()) { - QString err; - if (db_->updateColumnByKeyBatch(tableName_, "STATE", keyValuesList, stateValues, &err)) { - const bool prev = suppressItemChange_; - suppressItemChange_ = true; - for (const StateUpdate &upd : stateUpdates) { - if (upd.row < 0) continue; - QStandardItem *item = model_->item(upd.row, stateCol); - if (!item) continue; - item->setData(upd.value, Qt::EditRole); - item->setData(upd.value, Qt::UserRole + 1); - item->setData(false, Qt::UserRole + 2); - item->setText(displayForVariant(upd.value, false)); - item->setForeground(QBrush(view_->palette().color(QPalette::Text))); - } - suppressItemChange_ = prev; - } else { - qWarning().noquote() << QString("WARN: failed to update STATE selection: %1").arg(err); - } - } - } - - QHash groupAnyPending; - if (stateCol >= 0) { - groupAnyPending.reserve(groupRowsByKey_.size()); - for (auto it = groupRowsByKey_.begin(); it != groupRowsByKey_.end(); ++it) { - bool anyPending = false; - for (int memberRow : it.value()) { - QStandardItem *stateItem = model_->item(memberRow, stateCol); - if (!stateItem) continue; - const QString state = stateItem->data(Qt::UserRole + 1).toString().trimmed().toLower(); - if (state == "pending") { - anyPending = true; - break; - } - } - groupAnyPending.insert(it.key(), anyPending); - } - } - - for (const RowInfo &info : rows) { - const QString key = info.groupKey; - const bool isHeader = (groupHeaderRowByKey_.value(key, -1) == info.row); - const bool expanded = expandedGroups_.contains(key); - const int count = groupRowsByKey_.value(key).size(); - view_->setRowHidden(info.row, !(isHeader || expanded)); - - QStandardItem *nameItem = model_->item(info.row, nameCol); - if (!nameItem) continue; - const QString rawName = nameItem->data(Qt::UserRole + 1).toString(); - QString baseName = rawName; - QRegularExpression countSuffixRe("\\s*\\((\\d+)\\)\\s*$"); - QRegularExpressionMatch countMatch = countSuffixRe.match(baseName); - if (countMatch.hasMatch()) { - bool okCount = false; - const int suffixCount = countMatch.captured(1).toInt(&okCount); - if (okCount && (suffixCount == count || suffixCount == 1)) { - baseName = baseName.left(countMatch.capturedStart()).trimmed(); - } - } - QString displayName = baseName; - if (isHeader) { - if (count > 1) { - nameItem->setData(expanded ? expandedIcon : collapsedIcon, Qt::DecorationRole); - } else { - nameItem->setData(QVariant(), Qt::DecorationRole); - } - } else { - nameItem->setData(QVariant(), Qt::DecorationRole); - if (expanded) { - displayName = QString(" %1").arg(baseName); - } - } - const QString selectedObsId = selectedByKey.value(key); - const bool isSelected = (!selectedObsId.isEmpty() && info.obsId == selectedObsId); - QFont nameFont = nameItem->font(); - nameFont.setBold(isSelected); - nameItem->setFont(nameFont); - nameItem->setData(displayName, Qt::DisplayRole); - - if (stateCol >= 0) { - QStandardItem *stateItem = model_->item(info.row, stateCol); - if (stateItem) { - const bool collapsed = isHeader && !expanded; - if (collapsed && groupAnyPending.value(key, false)) { - stateItem->setData("pending", Qt::DisplayRole); - } else { - const QVariant val = stateItem->data(Qt::UserRole + 1); - const bool isNull = stateItem->data(Qt::UserRole + 2).toBool(); - stateItem->setData(displayForVariant(val, isNull), Qt::DisplayRole); - } - } - } - } - - suppressItemChange_ = prevSuppress; - updateGroupSequenceNumbers(); - } - - bool moveSingleAfterRow(int fromRow, int toRow, int setId, QString *error) { - const int obsIdCol = columnIndex("OBSERVATION_ID"); - const int obsOrderCol = columnIndex("OBS_ORDER"); - const int setIdCol = columnIndex("SET_ID"); - if (obsIdCol < 0 || obsOrderCol < 0) { - if (error) *error = "OBSERVATION_ID/OBS_ORDER columns missing."; - return false; - } - - QList infos; - infos.reserve(model_->rowCount()); - for (int row = 0; row < model_->rowCount(); ++row) { - QStandardItem *obsItem = model_->item(row, obsIdCol); - QStandardItem *orderItem = model_->item(row, obsOrderCol); - if (!obsItem || !orderItem) continue; - if (setIdCol >= 0 && setId >= 0) { - QStandardItem *setItem = model_->item(row, setIdCol); - if (!setItem || setItem->data(Qt::UserRole + 1).toInt() != setId) continue; - } - RowInfo info; - info.row = row; - info.obsId = obsItem->data(Qt::UserRole + 1).toString(); - info.obsOrder = orderItem->data(Qt::UserRole + 1).toInt(); - infos.append(info); - } - std::sort(infos.begin(), infos.end(), - [](const RowInfo &a, const RowInfo &b) { return a.obsOrder < b.obsOrder; }); - - const QString fromObsId = model_->item(fromRow, obsIdCol)->data(Qt::UserRole + 1).toString(); - const QString toObsId = model_->item(toRow, obsIdCol)->data(Qt::UserRole + 1).toString(); - int fromIdx = -1; - int toIdx = -1; - for (int i = 0; i < infos.size(); ++i) { - if (infos[i].obsId == fromObsId) fromIdx = i; - if (infos[i].obsId == toObsId) toIdx = i; - } - if (fromIdx < 0 || toIdx < 0) { - if (error) *error = "Target not found for reorder."; - return false; - } - if (fromIdx == toIdx) return true; - - RowInfo moving = infos.takeAt(fromIdx); - if (fromIdx < toIdx) toIdx--; - const int insertIdx = std::min(toIdx + 1, static_cast(infos.size())); - infos.insert(insertIdx, moving); - - QList obsIds; - QList orderValues; - obsIds.reserve(infos.size()); - orderValues.reserve(infos.size()); - for (int i = 0; i < infos.size(); ++i) { - obsIds << infos[i].obsId; - orderValues << (i + 1); - } - - QString err; - if (!db_->updateObsOrderByObservationId(tableName_, obsIds, orderValues, &err)) { - if (error) *error = err; - return false; - } - return true; - } - - bool moveSingleToPositionRow(int fromRow, int position, int setId, QString *error) { - const int obsIdCol = columnIndex("OBSERVATION_ID"); - const int obsOrderCol = columnIndex("OBS_ORDER"); - const int setIdCol = columnIndex("SET_ID"); - if (obsIdCol < 0 || obsOrderCol < 0) { - if (error) *error = "OBSERVATION_ID/OBS_ORDER columns missing."; - return false; - } - - QList infos; - infos.reserve(model_->rowCount()); - for (int row = 0; row < model_->rowCount(); ++row) { - QStandardItem *obsItem = model_->item(row, obsIdCol); - QStandardItem *orderItem = model_->item(row, obsOrderCol); - if (!obsItem || !orderItem) continue; - if (setIdCol >= 0 && setId >= 0) { - QStandardItem *setItem = model_->item(row, setIdCol); - if (!setItem || setItem->data(Qt::UserRole + 1).toInt() != setId) continue; - } - RowInfo info; - info.row = row; - info.obsId = obsItem->data(Qt::UserRole + 1).toString(); - info.obsOrder = orderItem->data(Qt::UserRole + 1).toInt(); - infos.append(info); - } - std::sort(infos.begin(), infos.end(), - [](const RowInfo &a, const RowInfo &b) { return a.obsOrder < b.obsOrder; }); - - const QString fromObsId = model_->item(fromRow, obsIdCol)->data(Qt::UserRole + 1).toString(); - int fromIdx = -1; - for (int i = 0; i < infos.size(); ++i) { - if (infos[i].obsId == fromObsId) { - fromIdx = i; - break; - } - } - if (fromIdx < 0) { - if (error) *error = "Target not found for reorder."; - return false; - } - if (infos.isEmpty()) return true; - const int targetIdx = std::clamp(position - 1, 0, static_cast(infos.size() - 1)); - if (fromIdx == targetIdx) return true; - - RowInfo moving = infos.takeAt(fromIdx); - int insertIdx = targetIdx; - if (fromIdx < targetIdx) insertIdx--; - if (insertIdx < 0) insertIdx = 0; - if (insertIdx > infos.size()) insertIdx = infos.size(); - infos.insert(insertIdx, moving); - - QList obsIds; - QList orderValues; - obsIds.reserve(infos.size()); - orderValues.reserve(infos.size()); - for (int i = 0; i < infos.size(); ++i) { - obsIds << infos[i].obsId; - orderValues << (i + 1); - } - - QString err; - if (!db_->updateObsOrderByObservationId(tableName_, obsIds, orderValues, &err)) { - if (error) *error = err; - return false; - } - return true; - } - - bool moveSingleToTopRow(int fromRow, int setId, QString *error) { - const int obsIdCol = columnIndex("OBSERVATION_ID"); - const int obsOrderCol = columnIndex("OBS_ORDER"); - const int setIdCol = columnIndex("SET_ID"); - if (obsIdCol < 0 || obsOrderCol < 0) { - if (error) *error = "OBSERVATION_ID/OBS_ORDER columns missing."; - return false; - } - - QList infos; - infos.reserve(model_->rowCount()); - for (int row = 0; row < model_->rowCount(); ++row) { - QStandardItem *obsItem = model_->item(row, obsIdCol); - QStandardItem *orderItem = model_->item(row, obsOrderCol); - if (!obsItem || !orderItem) continue; - if (setIdCol >= 0 && setId >= 0) { - QStandardItem *setItem = model_->item(row, setIdCol); - if (!setItem || setItem->data(Qt::UserRole + 1).toInt() != setId) continue; - } - RowInfo info; - info.row = row; - info.obsId = obsItem->data(Qt::UserRole + 1).toString(); - info.obsOrder = orderItem->data(Qt::UserRole + 1).toInt(); - infos.append(info); - } - std::sort(infos.begin(), infos.end(), - [](const RowInfo &a, const RowInfo &b) { return a.obsOrder < b.obsOrder; }); - - const QString fromObsId = model_->item(fromRow, obsIdCol)->data(Qt::UserRole + 1).toString(); - int fromIdx = -1; - for (int i = 0; i < infos.size(); ++i) { - if (infos[i].obsId == fromObsId) { - fromIdx = i; - break; - } - } - if (fromIdx < 0) { - if (error) *error = "Target not found for reorder."; - return false; - } - if (fromIdx == 0) return true; - - RowInfo moving = infos.takeAt(fromIdx); - infos.insert(0, moving); - - QList obsIds; - QList orderValues; - obsIds.reserve(infos.size()); - orderValues.reserve(infos.size()); - for (int i = 0; i < infos.size(); ++i) { - obsIds << infos[i].obsId; - orderValues << (i + 1); - } - - QString err; - if (!db_->updateObsOrderByObservationId(tableName_, obsIds, orderValues, &err)) { - if (error) *error = err; - return false; - } - return true; - } - - bool moveGroupAfterRow(int fromRow, int toRow, QString *error) { - const QString fromKey = groupKeyForRow(fromRow); - const QString toKey = groupKeyForRow(toRow); - if (fromKey.isEmpty() || toKey.isEmpty()) { - if (error) *error = "Group not found."; - return false; - } - if (fromKey == toKey) return true; - - const int obsIdCol = columnIndex("OBSERVATION_ID"); - const int obsOrderCol = columnIndex("OBS_ORDER"); - if (obsIdCol < 0 || obsOrderCol < 0) { - if (error) *error = "OBSERVATION_ID/OBS_ORDER columns missing."; - return false; - } - - struct GroupBlock { - QString key; - int minOrder = 0; - QVector members; - }; - - QHash blocks; - for (auto it = groupRowsByKey_.begin(); it != groupRowsByKey_.end(); ++it) { - GroupBlock block; - block.key = it.key(); - block.minOrder = std::numeric_limits::max(); - const int headerRow = groupHeaderRowByKey_.value(block.key, -1); - for (int row : it.value()) { - QStandardItem *obsItem = model_->item(row, obsIdCol); - QStandardItem *orderItem = model_->item(row, obsOrderCol); - if (!obsItem || !orderItem) continue; - RowInfo info; - info.row = row; - info.obsId = obsItem->data(Qt::UserRole + 1).toString(); - info.obsOrder = orderItem->data(Qt::UserRole + 1).toInt(); - block.members.append(info); - if (row == headerRow) { - block.minOrder = info.obsOrder; - } else if (block.minOrder == std::numeric_limits::max()) { - block.minOrder = info.obsOrder; - } else if (headerRow < 0) { - block.minOrder = std::min(block.minOrder, info.obsOrder); - } - } - std::sort(block.members.begin(), block.members.end(), - [](const RowInfo &a, const RowInfo &b) { return a.obsOrder < b.obsOrder; }); - blocks.insert(block.key, block); - } - - QVector order; - order.reserve(blocks.size()); - for (const GroupBlock &block : blocks) { - order.push_back(block); - } - std::sort(order.begin(), order.end(), - [](const GroupBlock &a, const GroupBlock &b) { return a.minOrder < b.minOrder; }); - - int fromIdx = -1; - int toIdx = -1; - for (int i = 0; i < order.size(); ++i) { - if (order[i].key == fromKey) fromIdx = i; - if (order[i].key == toKey) toIdx = i; - } - if (fromIdx < 0 || toIdx < 0) { - if (error) *error = "Group not found for reorder."; - return false; - } - if (fromIdx == toIdx) return true; - - GroupBlock moving = order.takeAt(fromIdx); - if (fromIdx < toIdx) toIdx--; - const int insertIdx = std::min(toIdx + 1, static_cast(order.size())); - order.insert(insertIdx, moving); - - QList obsIds; - QList orderValues; - int counter = 1; - for (const GroupBlock &block : order) { - for (const RowInfo &member : block.members) { - obsIds << member.obsId; - orderValues << counter++; - } - } - - QString err; - if (!db_->updateObsOrderByObservationId(tableName_, obsIds, orderValues, &err)) { - if (error) *error = err; - return false; - } - return true; - } - - bool moveGroupToPositionRow(int fromRow, int position, QString *error) { - const QString fromKey = groupKeyForRow(fromRow); - if (fromKey.isEmpty()) { - if (error) *error = "Group not found."; - return false; - } - - const int obsIdCol = columnIndex("OBSERVATION_ID"); - const int obsOrderCol = columnIndex("OBS_ORDER"); - if (obsIdCol < 0 || obsOrderCol < 0) { - if (error) *error = "OBSERVATION_ID/OBS_ORDER columns missing."; - return false; - } - - struct GroupBlock { - QString key; - int minOrder = 0; - QVector members; - }; - - QHash blocks; - for (auto it = groupRowsByKey_.begin(); it != groupRowsByKey_.end(); ++it) { - GroupBlock block; - block.key = it.key(); - block.minOrder = std::numeric_limits::max(); - const int headerRow = groupHeaderRowByKey_.value(block.key, -1); - for (int row : it.value()) { - QStandardItem *obsItem = model_->item(row, obsIdCol); - QStandardItem *orderItem = model_->item(row, obsOrderCol); - if (!obsItem || !orderItem) continue; - RowInfo info; - info.row = row; - info.obsId = obsItem->data(Qt::UserRole + 1).toString(); - info.obsOrder = orderItem->data(Qt::UserRole + 1).toInt(); - block.members.append(info); - if (row == headerRow) { - block.minOrder = info.obsOrder; - } else if (block.minOrder == std::numeric_limits::max()) { - block.minOrder = info.obsOrder; - } else if (headerRow < 0) { - block.minOrder = std::min(block.minOrder, info.obsOrder); - } - } - std::sort(block.members.begin(), block.members.end(), - [](const RowInfo &a, const RowInfo &b) { return a.obsOrder < b.obsOrder; }); - blocks.insert(block.key, block); - } - - QVector order; - order.reserve(blocks.size()); - for (const GroupBlock &block : blocks) { - order.push_back(block); - } - std::sort(order.begin(), order.end(), - [](const GroupBlock &a, const GroupBlock &b) { return a.minOrder < b.minOrder; }); - - int fromIdx = -1; - for (int i = 0; i < order.size(); ++i) { - if (order[i].key == fromKey) { - fromIdx = i; - break; - } - } - if (fromIdx < 0) { - if (error) *error = "Group not found for reorder."; - return false; - } - if (order.isEmpty()) return true; - const int targetIdx = std::clamp(position - 1, 0, static_cast(order.size() - 1)); - if (fromIdx == targetIdx) return true; - - GroupBlock moving = order.takeAt(fromIdx); - int insertIdx = targetIdx; - if (fromIdx < targetIdx) insertIdx--; - if (insertIdx < 0) insertIdx = 0; - if (insertIdx > order.size()) insertIdx = order.size(); - order.insert(insertIdx, moving); - - QList obsIds; - QList orderValues; - int counter = 1; - for (const GroupBlock &block : order) { - for (const RowInfo &member : block.members) { - obsIds << member.obsId; - orderValues << counter++; - } - } - - QString err; - if (!db_->updateObsOrderByObservationId(tableName_, obsIds, orderValues, &err)) { - if (error) *error = err; - return false; - } - return true; - } - - bool moveGroupToTopRow(int fromRow, QString *error) { - const QString fromKey = groupKeyForRow(fromRow); - if (fromKey.isEmpty()) { - if (error) *error = "Group not found."; - return false; - } - - const int obsIdCol = columnIndex("OBSERVATION_ID"); - const int obsOrderCol = columnIndex("OBS_ORDER"); - if (obsIdCol < 0 || obsOrderCol < 0) { - if (error) *error = "OBSERVATION_ID/OBS_ORDER columns missing."; - return false; - } - - struct GroupBlock { - QString key; - int minOrder = 0; - QVector members; - }; - - QHash blocks; - for (auto it = groupRowsByKey_.begin(); it != groupRowsByKey_.end(); ++it) { - GroupBlock block; - block.key = it.key(); - block.minOrder = std::numeric_limits::max(); - const int headerRow = groupHeaderRowByKey_.value(block.key, -1); - for (int row : it.value()) { - QStandardItem *obsItem = model_->item(row, obsIdCol); - QStandardItem *orderItem = model_->item(row, obsOrderCol); - if (!obsItem || !orderItem) continue; - RowInfo info; - info.row = row; - info.obsId = obsItem->data(Qt::UserRole + 1).toString(); - info.obsOrder = orderItem->data(Qt::UserRole + 1).toInt(); - block.members.append(info); - if (row == headerRow) { - block.minOrder = info.obsOrder; - } else if (block.minOrder == std::numeric_limits::max()) { - block.minOrder = info.obsOrder; - } else if (headerRow < 0) { - block.minOrder = std::min(block.minOrder, info.obsOrder); - } - } - std::sort(block.members.begin(), block.members.end(), - [](const RowInfo &a, const RowInfo &b) { return a.obsOrder < b.obsOrder; }); - blocks.insert(block.key, block); - } - - QVector order; - order.reserve(blocks.size()); - for (const GroupBlock &block : blocks) { - order.push_back(block); - } - std::sort(order.begin(), order.end(), - [](const GroupBlock &a, const GroupBlock &b) { return a.minOrder < b.minOrder; }); - - int fromIdx = -1; - for (int i = 0; i < order.size(); ++i) { - if (order[i].key == fromKey) { - fromIdx = i; - break; - } - } - if (fromIdx < 0) { - if (error) *error = "Group not found for reorder."; - return false; - } - if (fromIdx == 0) return true; - - GroupBlock moving = order.takeAt(fromIdx); - order.insert(0, moving); - - QList obsIds; - QList orderValues; - int counter = 1; - for (const GroupBlock &block : order) { - for (const RowInfo &member : block.members) { - obsIds << member.obsId; - orderValues << counter++; - } - } - - QString err; - if (!db_->updateObsOrderByObservationId(tableName_, obsIds, orderValues, &err)) { - if (error) *error = err; - return false; - } - return true; - } - - bool deleteGroupForRow(int row, QString *error) { - const QString key = groupKeyForRow(row); - if (key.isEmpty()) { - if (error) *error = "Group not found."; - return false; - } - const QList members = groupRowsByKey_.value(key); - if (members.isEmpty()) return true; - int setId = -1; - const int setIdCol = columnIndex("SET_ID"); - if (setIdCol >= 0) { - QStandardItem *setItem = model_->item(members.first(), setIdCol); - if (setItem) setId = setItem->data(Qt::UserRole + 1).toInt(); - } - QList keyList; - keyList.reserve(members.size()); - for (int memberRow : members) { - QVariantMap keyValues = keyValuesForRow(memberRow); - if (!keyValues.isEmpty()) { - keyList.append(keyValues); - } - } - QString err; - for (const QVariantMap &keyValues : keyList) { - if (!db_->deleteRecordByKey(tableName_, keyValues, &err)) { - if (error) *error = err; - return false; - } - } - if (setId >= 0) { - QString normError; - if (!normalizeObsOrderForSet(setId, &normError)) { - if (error) *error = normError; - return false; - } - } - return true; - } - - bool normalizeObsOrderForSet(int setId, QString *error) { - if (!db_ || !db_->isOpen()) { - if (error) *error = "Not connected"; - return false; - } - if (setId < 0) return true; - QList cols; - if (!db_->loadColumns(tableName_, cols, error)) { - return false; - } - QList> rows; - if (!db_->fetchRows(tableName_, cols, "SET_ID", QString::number(setId), - "", "", "OBS_ORDER", rows, error)) { - return false; - } - int obsIdCol = -1; - for (int i = 0; i < cols.size(); ++i) { - if (cols[i].name.compare("OBSERVATION_ID", Qt::CaseInsensitive) == 0) { - obsIdCol = i; - break; - } - } - if (obsIdCol < 0) return true; - QList obsIds; - QList orderValues; - obsIds.reserve(rows.size()); - orderValues.reserve(rows.size()); - for (int i = 0; i < rows.size(); ++i) { - if (obsIdCol >= rows[i].size()) continue; - const QVariant obsId = rows[i].at(obsIdCol); - obsIds << obsId; - orderValues << (i + 1); - } - QString err; - if (!db_->updateObsOrderByObservationId(tableName_, obsIds, orderValues, &err)) { - if (error) *error = err; - return false; - } - return true; - } - - bool moveRowAfter(int from, int to, QString *errorOut) { - if (!allowReorder_) return false; - if (!searchEdit_->text().trimmed().isEmpty()) { - showInfo(this, "Reorder disabled", - "Clear the search filter before reordering."); - return false; - } - if (from < 0 || to < 0 || from >= model_->rowCount() || to >= model_->rowCount()) return false; - if (from == to) return true; - if (!db_ || !db_->isOpen()) { - showWarning(this, "Reorder failed", "Not connected"); - return false; - } - - const SwapInfo src = swapInfoForRow(from); - const SwapInfo dst = swapInfoForRow(to); - if (!src.valid || !dst.valid) { - showWarning(this, "Reorder failed", - "Missing OBSERVATION_ID/OBS_ORDER values."); - return false; - } - - const int setIdCol = columnIndex("SET_ID"); - int setId = -1; - if (setIdCol >= 0) { - QStandardItem *setItem = model_->item(from, setIdCol); - if (setItem) setId = setItem->data(Qt::UserRole + 1).toInt(); - } - - ViewState state = captureViewState(); - const bool groupMove = groupingEnabled_ && isGroupHeaderRow(from) && - !expandedGroups_.contains(groupKeyForRow(from)); - - QString error; - if (groupMove) { - if (!moveGroupAfterRow(from, to, &error)) { - if (errorOut) *errorOut = error; - showWarning(this, "Reorder failed", error.isEmpty() ? "Group move failed." : error); - refresh(); - return false; - } - } else { - if (!moveSingleAfterRow(from, to, setId, &error)) { - if (errorOut) *errorOut = error; - showWarning(this, "Reorder failed", error.isEmpty() ? "Move failed." : error); - refresh(); - return false; - } - } - - refreshWithState(state); - emit dataMutated(); - return true; - } - - bool moveSingleAfterRowWithRefresh(int fromRow, int toRow, QString *errorOut) { - if (!allowReorder_) return false; - if (!searchEdit_->text().trimmed().isEmpty()) { - showInfo(this, "Reorder disabled", - "Clear the search filter before reordering."); - return false; - } - if (fromRow < 0 || toRow < 0 || fromRow >= model_->rowCount() || toRow >= model_->rowCount()) { - return false; - } - if (fromRow == toRow) return true; - if (!db_ || !db_->isOpen()) { - showWarning(this, "Reorder failed", "Not connected"); - return false; - } - - const int setIdCol = columnIndex("SET_ID"); - int setId = -1; - if (setIdCol >= 0) { - QStandardItem *setItem = model_->item(fromRow, setIdCol); - if (setItem) setId = setItem->data(Qt::UserRole + 1).toInt(); - } - - ViewState state = captureViewState(); - QString error; - if (!moveSingleAfterRow(fromRow, toRow, setId, &error)) { - if (errorOut) *errorOut = error; - showWarning(this, "Reorder failed", error.isEmpty() ? "Move failed." : error); - refresh(); - return false; - } - refreshWithState(state); - emit dataMutated(); - return true; - } - - int previousVisibleRow(int row) const { - for (int r = row - 1; r >= 0; --r) { - if (!view_->isRowHidden(r)) return r; - } - return -1; - } - - int nextVisibleRow(int row) const { - for (int r = row + 1; r < model_->rowCount(); ++r) { - if (!view_->isRowHidden(r)) return r; - } - return -1; - } - - int firstVisibleRow() const { - for (int r = 0; r < model_->rowCount(); ++r) { - if (!view_->isRowHidden(r)) return r; - } - return -1; - } - - int lastVisibleRow() const { - for (int r = model_->rowCount() - 1; r >= 0; --r) { - if (!view_->isRowHidden(r)) return r; - } - return -1; - } - - void applyHiddenColumns() { - if (!view_ || columns_.isEmpty()) return; - for (int i = 0; i < columns_.size(); ++i) { - const QString name = columns_.at(i).name.toUpper(); - const bool hide = hiddenColumns_.contains(name); - view_->setColumnHidden(i, hide); - } - } - - void applyColumnOrderRules() { - if (!view_ || columns_.isEmpty() || columnAfterRules_.isEmpty()) return; - QHeaderView *header = view_->horizontalHeader(); - for (const auto &rule : columnAfterRules_) { - const int anchor = columnIndex(rule.first); - const int column = columnIndex(rule.second); - if (anchor < 0 || column < 0) continue; - const int anchorVisual = header->visualIndex(anchor); - int columnVisual = header->visualIndex(column); - if (anchorVisual < 0 || columnVisual < 0) continue; - const int target = anchorVisual + 1; - if (columnVisual == target) continue; - header->moveSection(columnVisual, target); - } - } - - void updateGroupSequenceNumbers() { - if (!view_ || !model_) return; - if (!groupingEnabled_ || groupRowsByKey_.isEmpty()) { - for (int row = 0; row < model_->rowCount(); ++row) { - model_->setHeaderData(row, Qt::Vertical, QVariant()); - } - return; - } - - const int obsOrderCol = columnIndex("OBS_ORDER"); - struct GroupOrder { - QString key; - int order = 0; - }; - QVector groupOrder; - groupOrder.reserve(groupRowsByKey_.size()); - for (auto it = groupRowsByKey_.begin(); it != groupRowsByKey_.end(); ++it) { - int minOrder = std::numeric_limits::max(); - const int headerRow = groupHeaderRowByKey_.value(it.key(), -1); - if (obsOrderCol >= 0) { - for (int row : it.value()) { - QStandardItem *orderItem = model_->item(row, obsOrderCol); - if (!orderItem) continue; - const int orderVal = orderItem->data(Qt::UserRole + 1).toInt(); - if (row == headerRow) { - minOrder = orderVal; - break; - } - if (orderVal < minOrder) minOrder = orderVal; - } - } else if (!it.value().isEmpty()) { - minOrder = it.value().first(); - } - if (minOrder == std::numeric_limits::max()) minOrder = 0; - groupOrder.append({it.key(), minOrder}); - } - std::sort(groupOrder.begin(), groupOrder.end(), - [](const GroupOrder &a, const GroupOrder &b) { return a.order < b.order; }); - - QHash seqByKey; - int seq = 1; - for (const GroupOrder &group : groupOrder) { - seqByKey.insert(group.key, seq++); - } - - for (int row = 0; row < model_->rowCount(); ++row) { - const QString key = groupKeyByRow_.value(row); - if (key.isEmpty()) { - model_->setHeaderData(row, Qt::Vertical, QVariant()); - } else { - const int num = seqByKey.value(key, row + 1); - model_->setHeaderData(row, Qt::Vertical, QString::number(num)); - } - } - } - - QString headerSettingsKey() const { - if (tableName_.isEmpty()) return QString(); - return QString("tableHeaders/%1/state").arg(tableName_); - } - - QString groupingSettingsKey(const QString &suffix) const { - if (tableName_.isEmpty()) return QString(); - return QString("tableGrouping/%1/%2").arg(tableName_, suffix); - } - - bool restoreHeaderState() { - if (!view_ || tableName_.isEmpty()) return false; - QSettings settings(kSettingsOrg, kSettingsApp); - const QByteArray state = settings.value(headerSettingsKey()).toByteArray(); - if (state.isEmpty()) return false; - headerStateUpdating_ = true; - const bool ok = view_->horizontalHeader()->restoreState(state); - headerStateUpdating_ = false; - if (ok) { - headerStateLoaded_ = true; - } - return ok; - } - - void scheduleHeaderStateSave() { - if (headerStateUpdating_ || !headerSaveTimer_) return; - headerSaveTimer_->start(200); - } - - void saveHeaderState() { - if (headerStateUpdating_ || !view_ || tableName_.isEmpty()) return; - QSettings settings(kSettingsOrg, kSettingsApp); - settings.setValue(headerSettingsKey(), view_->horizontalHeader()->saveState()); - headerStateLoaded_ = true; - } - - void loadGroupingState() { - manualUngroupObsIds_.clear(); - manualGroupKeyByObsId_.clear(); - selectedObsIdByHeader_.clear(); - if (tableName_.isEmpty()) return; - QSettings settings(kSettingsOrg, kSettingsApp); - const QStringList ungrouped = settings.value(groupingSettingsKey("manualUngroup")).toStringList(); - for (const QString &obsId : ungrouped) { - if (!obsId.trimmed().isEmpty()) manualUngroupObsIds_.insert(obsId.trimmed()); - } - const QStringList groups = settings.value(groupingSettingsKey("manualGroups")).toStringList(); - for (const QString &entry : groups) { - const int idx = entry.indexOf('='); - if (idx <= 0) continue; - const QString obsId = entry.left(idx).trimmed(); - const QString key = entry.mid(idx + 1).trimmed(); - if (obsId.isEmpty() || key.isEmpty()) continue; - manualGroupKeyByObsId_.insert(obsId, key); - } - const QStringList selected = settings.value(groupingSettingsKey("selectedObs")).toStringList(); - for (const QString &entry : selected) { - const int idx = entry.indexOf('='); - if (idx <= 0) continue; - const QString headerObsId = entry.left(idx).trimmed(); - const QString selectedObsId = entry.mid(idx + 1).trimmed(); - if (headerObsId.isEmpty() || selectedObsId.isEmpty()) continue; - selectedObsIdByHeader_.insert(headerObsId, selectedObsId); - } - } - - void saveGroupingState() { - if (tableName_.isEmpty()) return; - QSettings settings(kSettingsOrg, kSettingsApp); - QStringList ungrouped = manualUngroupObsIds_.values(); - ungrouped.sort(); - settings.setValue(groupingSettingsKey("manualUngroup"), ungrouped); - QStringList groups; - groups.reserve(manualGroupKeyByObsId_.size()); - for (auto it = manualGroupKeyByObsId_.begin(); it != manualGroupKeyByObsId_.end(); ++it) { - groups << QString("%1=%2").arg(it.key(), it.value()); - } - groups.sort(); - settings.setValue(groupingSettingsKey("manualGroups"), groups); - - QStringList selected; - selected.reserve(selectedObsIdByHeader_.size()); - for (auto it = selectedObsIdByHeader_.begin(); it != selectedObsIdByHeader_.end(); ++it) { - if (it.key().trimmed().isEmpty() || it.value().trimmed().isEmpty()) continue; - selected << QString("%1=%2").arg(it.key(), it.value()); - } - selected.sort(); - settings.setValue(groupingSettingsKey("selectedObs"), selected); - } - - void deleteRow(int row) { - if (!allowDelete_) return; - if (row < 0 || row >= model_->rowCount()) return; - if (groupingEnabled_ && isGroupHeaderRow(row) && !expandedGroups_.contains(groupKeyForRow(row))) { - if (QMessageBox::question(this, "Delete Target Group", - "Delete all targets in this group?", - QMessageBox::Yes | QMessageBox::No) != QMessageBox::Yes) { - return; - } - QString error; - if (!deleteGroupForRow(row, &error)) { - showWarning(this, "Delete failed", error); - return; - } - refreshWithState(captureViewState()); - emit dataMutated(); - return; - } - - if (QMessageBox::question(this, "Delete Target", "Delete the selected target?", - QMessageBox::Yes | QMessageBox::No) != QMessageBox::Yes) { - return; - } - QVariantMap keyValues = keyValuesForRow(row); - if (keyValues.isEmpty()) { - showWarning(this, "Delete failed", "Missing primary key values."); - return; - } - QString error; - if (!db_->deleteRecordByKey(tableName_, keyValues, &error)) { - showWarning(this, "Delete failed", error); - return; - } - const int setIdCol = columnIndex("SET_ID"); - const int obsOrderCol = columnIndex("OBS_ORDER"); - int setId = -1; - int oldPos = -1; - if (setIdCol >= 0 && obsOrderCol >= 0) { - QStandardItem *setItem = model_->item(row, setIdCol); - QStandardItem *orderItem = model_->item(row, obsOrderCol); - if (setItem) setId = setItem->data(Qt::UserRole + 1).toInt(); - if (orderItem) oldPos = orderItem->data(Qt::UserRole + 1).toInt(); - } - - ViewState state = captureViewState(); - suppressItemChange_ = true; - model_->removeRow(row); - suppressItemChange_ = false; - - if (setId >= 0 && oldPos >= 0) { - QString shiftError; - if (!db_->shiftObsOrderAfterDelete(tableName_, setId, oldPos, &shiftError)) { - showWarning(this, "Reorder failed", shiftError); - refresh(); - return; - } - } - refreshWithState(state); - emit dataMutated(); - } - - void duplicateRow(int row) { - if (!db_ || !db_->isOpen()) return; - if (row < 0 || row >= model_->rowCount()) return; - - const int setIdCol = columnIndex("SET_ID"); - const int obsOrderCol = columnIndex("OBS_ORDER"); - if (setIdCol < 0 || obsOrderCol < 0) { - // Fallback: simple duplicate to end if ordering columns missing. - QVariantMap values; - QSet nullColumns; - for (int c = 0; c < columns_.size(); ++c) { - const ColumnMeta &meta = columns_.at(c); - if (meta.isAutoIncrement()) continue; - QStandardItem *item = model_->item(row, c); - if (!item) continue; - const bool isNull = item->data(Qt::UserRole + 2).toBool(); - const QVariant value = item->data(Qt::UserRole + 1); - if (isNull) { - nullColumns.insert(meta.name); - } else { - values.insert(meta.name, value); - } - } - QString error; - if (!insertRecord(values, nullColumns, &error)) { - showWarning(this, "Duplicate failed", error); - return; - } - refreshWithState(captureViewState()); - emit dataMutated(); - return; - } - - QStandardItem *setItem = model_->item(row, setIdCol); - if (!setItem) return; - const int setId = setItem->data(Qt::UserRole + 1).toInt(); - if (setId <= 0) return; - - const QString groupKey = groupKeyForRow(row); - const bool groupDuplicate = groupingEnabled_ && isGroupHeaderRow(row) && - !expandedGroups_.contains(groupKey); - - QVector sourceRows; - if (groupDuplicate && !groupKey.isEmpty()) { - for (int memberRow : groupRowsByKey_.value(groupKey)) { - sourceRows.append(memberRow); - } - } else { - sourceRows.append(row); - } - if (sourceRows.isEmpty()) return; - - std::sort(sourceRows.begin(), sourceRows.end(), [&](int a, int b) { - QStandardItem *orderA = model_->item(a, obsOrderCol); - QStandardItem *orderB = model_->item(b, obsOrderCol); - const int oa = orderA ? orderA->data(Qt::UserRole + 1).toInt() : 0; - const int ob = orderB ? orderB->data(Qt::UserRole + 1).toInt() : 0; - return oa < ob; - }); - - QStandardItem *orderItem = model_->item(sourceRows.last(), obsOrderCol); - if (!orderItem) return; - const int lastOrder = orderItem->data(Qt::UserRole + 1).toInt(); - const int insertPos = lastOrder + 1; - const int count = sourceRows.size(); - - QString error; - if (!db_->shiftObsOrderForInsert(tableName_, setId, insertPos, count, &error)) { - showWarning(this, "Duplicate failed", error.isEmpty() ? "Failed to insert target(s)." : error); - return; - } - - bool insertedAll = true; - for (int i = 0; i < sourceRows.size(); ++i) { - const int srcRow = sourceRows.at(i); - QVariantMap values; - QSet nullColumns; - for (int c = 0; c < columns_.size(); ++c) { - const ColumnMeta &meta = columns_.at(c); - if (meta.isAutoIncrement()) continue; - QStandardItem *item = model_->item(srcRow, c); - if (!item) continue; - const bool isNull = item->data(Qt::UserRole + 2).toBool(); - const QVariant value = item->data(Qt::UserRole + 1); - if (isNull) { - nullColumns.insert(meta.name); - } else { - values.insert(meta.name, value); - } - } - values.insert("OBS_ORDER", insertPos + i); - nullColumns.remove("OBS_ORDER"); - if (!insertRecord(values, nullColumns, &error)) { - insertedAll = false; - showWarning(this, "Duplicate failed", error); - break; - } - } - - if (!insertedAll) { - QString normErr; - normalizeObsOrderForSet(setId, &normErr); - refreshWithState(captureViewState()); - emit dataMutated(); - return; - } - - ViewState state = captureViewState(); - refreshWithState(state); - emit dataMutated(); - - const QStringList newObsIds = obsIdsForObsOrderRange(setId, insertPos, count); - if (!newObsIds.isEmpty()) { - if (groupDuplicate) { - const QString groupId = - QString("MANUAL:%1").arg(QUuid::createUuid().toString(QUuid::WithoutBraces)); - for (const QString &newObsId : newObsIds) { - manualUngroupObsIds_.remove(newObsId); - manualGroupKeyByObsId_.insert(newObsId, groupId); - } - saveGroupingState(); - applyGrouping(); - } else { - int groupSize = 1; - if (groupingEnabled_ && !groupKey.isEmpty()) { - groupSize = groupRowsByKey_.value(groupKey).size(); - } - if (groupSize <= 1) { - for (const QString &newObsId : newObsIds) { - manualUngroupObsIds_.insert(newObsId); - manualGroupKeyByObsId_.remove(newObsId); - } - saveGroupingState(); - applyGrouping(); - } - } - } - } - - QVariantMap keyValuesForRow(int row) const { - QVariantMap keyValues; - for (int c = 0; c < columns_.size(); ++c) { - if (!columns_[c].isPrimaryKey()) continue; - QStandardItem *item = model_->item(row, c); - if (!item) continue; - const QVariant keyValue = item->data(Qt::UserRole + 1); - const bool keyIsNull = item->data(Qt::UserRole + 2).toBool(); - if (!keyValue.isValid() || keyIsNull) return QVariantMap(); - keyValues.insert(columns_[c].name, keyValue); - } - return keyValues; - } - - int columnIndex(const QString &name) const { - for (int i = 0; i < columns_.size(); ++i) { - if (columns_[i].name.compare(name, Qt::CaseInsensitive) == 0) return i; - } - return -1; - } - - bool isColumnBulkEditable(int col) const { - if (col < 0 || col >= columns_.size()) return false; - const ColumnMeta &meta = columns_.at(col); - if (meta.isPrimaryKey() || meta.isAutoIncrement()) return false; - if (hiddenColumns_.contains(meta.name.toUpper())) return false; - return true; - } - - QStringList editableColumnsForBulkEdit() const { - QStringList result; - for (const ColumnMeta &meta : columns_) { - if (meta.isPrimaryKey() || meta.isAutoIncrement()) continue; - if (hiddenColumns_.contains(meta.name.toUpper())) continue; - result << meta.name; - } - return result; - } - - QStringList obsIdsInView() const { - QStringList obsIds; - const int obsIdCol = columnIndex("OBSERVATION_ID"); - if (obsIdCol < 0) return obsIds; - QSet seen; - for (int row = 0; row < model_->rowCount(); ++row) { - QStandardItem *item = model_->item(row, obsIdCol); - if (!item) continue; - const QString obsId = item->data(Qt::UserRole + 1).toString(); - if (obsId.isEmpty() || seen.contains(obsId)) continue; - seen.insert(obsId); - obsIds.append(obsId); - } - return obsIds; - } - - QStringList obsIdsForObsOrderRange(int setId, int startOrder, int count) const { - QStringList obsIds; - if (count <= 0) return obsIds; - const int obsIdCol = columnIndex("OBSERVATION_ID"); - const int obsOrderCol = columnIndex("OBS_ORDER"); - const int setIdCol = columnIndex("SET_ID"); - if (obsIdCol < 0 || obsOrderCol < 0 || setIdCol < 0) return obsIds; - QMap ordered; - const int endOrder = startOrder + count - 1; - for (int row = 0; row < model_->rowCount(); ++row) { - QStandardItem *setItem = model_->item(row, setIdCol); - QStandardItem *orderItem = model_->item(row, obsOrderCol); - QStandardItem *obsItem = model_->item(row, obsIdCol); - if (!setItem || !orderItem || !obsItem) continue; - const int rowSetId = setItem->data(Qt::UserRole + 1).toInt(); - if (rowSetId != setId) continue; - const int orderVal = orderItem->data(Qt::UserRole + 1).toInt(); - if (orderVal < startOrder || orderVal > endOrder) continue; - const QString obsId = obsItem->data(Qt::UserRole + 1).toString(); - if (!obsId.isEmpty()) ordered.insert(orderVal, obsId); - } - for (auto it = ordered.begin(); it != ordered.end(); ++it) { - obsIds.append(it.value()); - } - return obsIds; - } - - void revertItem(QStandardItem *item, const QVariant &oldValue, bool oldIsNull) { - suppressItemChange_ = true; - item->setData(oldIsNull ? QVariant() : oldValue, Qt::EditRole); - item->setData(oldIsNull ? QVariant() : oldValue, Qt::UserRole + 1); - item->setData(oldIsNull, Qt::UserRole + 2); - item->setText(displayForVariant(oldValue, oldIsNull)); - item->setForeground(QBrush(oldIsNull ? view_->palette().color(QPalette::Disabled, QPalette::Text) - : view_->palette().color(QPalette::Text))); - suppressItemChange_ = false; - } - - bool insertRecord(const QVariantMap &values, const QSet &nullColumns, QString *error) { - if (!db_) { - if (error) *error = "Not connected"; - return false; - } - return db_->insertRecord(tableName_, columns_, values, nullColumns, error); - } - - bool updateRecord(const QVariantMap &values, const QSet &nullColumns, - const QVariantMap &keyValues, QString *error) { - if (!db_) { - if (error) *error = "Not connected"; - return false; - } - return db_->updateRecord(tableName_, columns_, values, nullColumns, keyValues, error); - } - - DbClient *db_ = nullptr; - QString tableName_; - QList columns_; - QStandardItemModel *model_ = nullptr; - ReorderTableView *view_ = nullptr; - - QPushButton *refreshButton_ = nullptr; - QPushButton *addButton_ = nullptr; - QLabel *statusLabel_ = nullptr; - - QLabel *searchLabel_ = nullptr; - QLineEdit *searchEdit_ = nullptr; - QPushButton *searchApply_ = nullptr; - QPushButton *searchClear_ = nullptr; - QString searchColumn_; - QString fixedFilterColumn_; - QString fixedFilterValue_; - QString orderByColumn_; - bool sortingEnabled_ = true; - bool allowReorder_ = false; - bool allowDelete_ = false; - bool allowColumnHeaderBulkEdit_ = false; - bool quickAddEnabled_ = false; - bool quickAddInsertAtTop_ = false; - std::function &)> normalizer_; - std::function &, QString *)> quickAddBuilder_; - QStringList hiddenColumns_; - QVector> columnAfterRules_; - - bool suppressItemChange_ = false; - bool headerStateLoaded_ = false; - bool headerStateUpdating_ = false; - QTimer *headerSaveTimer_ = nullptr; - bool headerRulesPending_ = false; - - bool groupingEnabled_ = false; - QHash> groupRowsByKey_; - QHash groupHeaderRowByKey_; - QHash groupKeyByRow_; - QSet expandedGroups_; - QSet manualUngroupObsIds_; - QHash manualGroupKeyByObsId_; - QHash selectedObsIdByHeader_; - bool groupingStateLoaded_ = false; - QPointer moveToDialog_; -}; - -class TimelineCanvas : public QWidget { - Q_OBJECT -public: - explicit TimelineCanvas(QWidget *parent = nullptr) : QWidget(parent) { - setMouseTracking(true); - } - - void setData(const TimelineData &data) { - data_ = data; - setMinimumHeight(sizeHint().height()); - updateGeometry(); - update(); - } - - void clear() { - data_ = TimelineData(); - selectedObsId_.clear(); - setMinimumHeight(sizeHint().height()); - updateGeometry(); - update(); - } - - void setSelectedObsId(const QString &obsId) { - if (selectedObsId_ == obsId) return; - selectedObsId_ = obsId; - setMinimumHeight(sizeHint().height()); - updateGeometry(); - update(); - } - - QSize sizeHint() const override { - int height = topMargin_ + bottomMargin_; - if (data_.targets.isEmpty()) { - height += rowHeight_; - } else { - for (int i = 0; i < data_.targets.size(); ++i) { - height += rowHeightForIndex(i); - } - } - const int width = leftMargin_ + rightMargin_ + 720; - return QSize(width, height); - } - -signals: - void targetSelected(const QString &obsId); - void targetReorderRequested(const QString &fromObsId, const QString &toObsId); - void flagClicked(const QString &obsId, const QString &flagText); - void contextMenuRequested(const QString &obsId, const QPoint &globalPos); - void exptimeEditRequested(const QString &obsId); - -protected: - void mouseDoubleClickEvent(QMouseEvent *event) override { - if (data_.targets.isEmpty()) return; - const int index = rowAt(event->pos()); - if (index < 0 || index >= data_.targets.size()) return; - const TimelineTarget &target = data_.targets.at(index); - QVector> segments = target.segments; - if (segments.isEmpty() && target.startUtc.isValid() && target.endUtc.isValid()) { - segments.append({target.startUtc, target.endUtc}); - } - if (segments.isEmpty()) return; - if (data_.timesUtc.isEmpty()) return; - const qint64 t0 = data_.timesUtc.first().toMSecsSinceEpoch(); - const qint64 t1 = data_.timesUtc.last().toMSecsSinceEpoch(); - const QRect plotRect = plotArea(); - const QRect rowRect = rowArea(plotRect, index); - for (const auto &segment : segments) { - const qint64 s = segment.first.toMSecsSinceEpoch(); - const qint64 e = segment.second.toMSecsSinceEpoch(); - if (e <= t0 || s >= t1) continue; - const double x1 = timeToX(std::max(s, t0), t0, t1, rowRect); - const double x2 = timeToX(std::min(e, t1), t0, t1, rowRect); - const double y = rowRect.center().y() - barHeight_ / 2.0; - QRectF bar(x1, y, x2 - x1, barHeight_); - if (bar.contains(event->pos())) { - if (!target.obsId.isEmpty()) { - emit exptimeEditRequested(target.obsId); - } - event->accept(); - return; - } - } - QWidget::mouseDoubleClickEvent(event); - } - void contextMenuEvent(QContextMenuEvent *event) override { - if (data_.targets.isEmpty()) return; - const int index = rowAt(event->pos()); - if (index < 0 || index >= data_.targets.size()) return; - const QString obsId = data_.targets.at(index).obsId; - if (!obsId.isEmpty()) { - emit contextMenuRequested(obsId, event->globalPos()); - } - } - - void paintEvent(QPaintEvent *) override { - QPainter painter(this); - painter.setRenderHint(QPainter::Antialiasing, true); - painter.fillRect(rect(), palette().base()); - - if (data_.targets.isEmpty() || data_.timesUtc.isEmpty()) { - painter.setPen(palette().color(QPalette::Disabled, QPalette::Text)); - painter.drawText(rect(), Qt::AlignCenter, "Run OTM to view timeline."); - return; - } - - const QRect plotRect = plotArea(); - painter.setPen(palette().color(QPalette::Mid)); - painter.drawRect(plotRect.adjusted(0, 0, -1, -1)); - - const qint64 t0 = data_.timesUtc.first().toMSecsSinceEpoch(); - const qint64 t1 = data_.timesUtc.last().toMSecsSinceEpoch(); - if (t1 <= t0) return; - - drawTwilightLines(&painter, plotRect, t0, t1); - drawTimeAxis(&painter, plotRect, t0, t1); - - drawRowSeparators(&painter, plotRect); - - const double airmassMin = 1.0; - const double airmassMax = data_.airmassLimit > 0.0 ? data_.airmassLimit : 4.0; - - drawIdleGaps(&painter, plotRect, t0, t1); - - for (int i = 0; i < data_.targets.size(); ++i) { - const TimelineTarget &target = data_.targets.at(i); - const QRect rowRect = rowArea(plotRect, i); - const bool selected = (!selectedObsId_.isEmpty() && target.obsId == selectedObsId_); - const bool observed = target.observed || !target.segments.isEmpty() || - (target.startUtc.isValid() && target.endUtc.isValid()); - - QColor labelColor = palette().color(QPalette::Text); - if (!observed) { - labelColor = palette().color(QPalette::Disabled, QPalette::Text); - } else if (target.severity == 2) { - labelColor = QColor(220, 70, 70); - } else if (target.severity == 1) { - labelColor = QColor(255, 165, 0); - } - QString displayName = target.name.isEmpty() ? target.obsId : target.name; - if (!displayName.isEmpty()) { - QRegularExpression countSuffixRe("\\s*\\((\\d+)\\)\\s*$"); - displayName = displayName.left(countSuffixRe.match(displayName).capturedStart()).trimmed(); - } - const int labelWidth = leftMargin_ - 6; - const int orderWidth = 44; - const int gap = 8; - QRect orderRect(0, rowRect.top(), std::min(orderWidth, labelWidth), rowRect.height()); - QRect nameRect(orderRect.right() + gap, rowRect.top(), - std::max(0, labelWidth - orderRect.width() - gap), rowRect.height()); - if (target.obsOrder > 0) { - QColor orderColor = labelColor.darker(140); - painter.setPen(orderColor); - painter.drawText(orderRect, Qt::AlignLeft | Qt::AlignVCenter, - QString::number(target.obsOrder)); - } - painter.setPen(labelColor); - painter.drawText(nameRect, Qt::AlignRight | Qt::AlignVCenter, displayName); - - drawExposureBar(&painter, rowRect, target, t0, t1, selected, i, observed); - drawAirmassCurve(&painter, rowRect, target, t0, t1, airmassMin, airmassMax, selected, i, observed); - if (selected) { - drawAirmassCurveLabels(&painter, rowRect, target, t0, t1, airmassMin, airmassMax); - } - - if (!target.flag.trimmed().isEmpty()) { - QRect flagRect(plotRect.right() + 6, rowRect.top(), rightLabelWidth_, rowRect.height()); - QFontMetrics fm(painter.font()); - const QString label = fm.elidedText(target.flag.trimmed(), Qt::ElideRight, flagRect.width() - 4); - painter.setPen(labelColor); - painter.drawText(flagRect, Qt::AlignVCenter | Qt::AlignLeft, label); - } - } - - drawDropIndicator(&painter, plotRect); - } - - void mousePressEvent(QMouseEvent *event) override { - if (data_.targets.isEmpty()) return; - pressPos_ = event->pos(); - pressIndex_ = rowAt(event->pos()); - dragging_ = false; - if (pressIndex_ >= 0 && pressIndex_ < data_.targets.size()) { - const QRect plotRect = plotArea(); - const QRect rowRect = rowArea(plotRect, pressIndex_); - const QString obsId = data_.targets.at(pressIndex_).obsId; - const QString flagText = data_.targets.at(pressIndex_).flag.trimmed(); - if (!flagText.isEmpty()) { - QRect flagRect(plotRect.right() + 6, rowRect.top(), rightLabelWidth_, rowRect.height()); - if (flagRect.contains(event->pos())) { - emit flagClicked(obsId, flagText); - } - } - if (!obsId.isEmpty()) { - selectedObsId_ = obsId; - emit targetSelected(obsId); - update(); - } - } - } - - void mouseMoveEvent(QMouseEvent *event) override { - if (pressIndex_ < 0) return; - if (!dragging_) { - if ((event->pos() - pressPos_).manhattanLength() < QApplication::startDragDistance()) { - return; - } - dragging_ = true; - setCursor(Qt::ClosedHandCursor); - } - int hover = rowAt(event->pos()); - if (hover < 0 && !data_.targets.isEmpty()) { - const QRect plotRect = plotArea(); - if (event->pos().y() < plotRect.top()) { - hover = 0; - } else if (event->pos().y() > plotRect.bottom()) { - hover = data_.targets.size() - 1; - } - } - if (hover >= 0 && hover < data_.targets.size()) { - const QRect plotRect = plotArea(); - const QRect hoverRect = rowArea(plotRect, hover); - dragInsertAbove_ = event->pos().y() < hoverRect.center().y(); - } else { - dragInsertAbove_ = false; - } - if (hover != dragHoverIndex_) { - dragHoverIndex_ = hover; - update(); - } - } - - void mouseReleaseEvent(QMouseEvent *event) override { - if (dragging_) { - int targetIndex = rowAt(event->pos()); - if (targetIndex < 0) targetIndex = dragHoverIndex_; - if (pressIndex_ >= 0 && targetIndex >= 0 && pressIndex_ != targetIndex) { - const QString fromObs = data_.targets.at(pressIndex_).obsId; - if (!fromObs.isEmpty()) { - const QRect plotRect = plotArea(); - const QRect targetRect = rowArea(plotRect, targetIndex); - const bool insertAbove = event->pos().y() < targetRect.center().y(); - if (insertAbove) { - if (targetIndex == 0) { - emit targetReorderRequested(fromObs, QString()); - } else { - const QString prevObs = data_.targets.at(targetIndex - 1).obsId; - if (!prevObs.isEmpty()) { - emit targetReorderRequested(fromObs, prevObs); - } - } - } else { - const QString toObs = data_.targets.at(targetIndex).obsId; - if (!toObs.isEmpty()) { - emit targetReorderRequested(fromObs, toObs); - } - } - } - } - } - dragging_ = false; - pressIndex_ = -1; - dragHoverIndex_ = -1; - dragInsertAbove_ = false; - unsetCursor(); - update(); - } - -private: - TimelineData data_; - QString selectedObsId_; - QPoint pressPos_; - int pressIndex_ = -1; - bool dragging_ = false; - int dragHoverIndex_ = -1; - bool dragInsertAbove_ = false; - - const int leftMargin_ = 200; - const int rightLabelWidth_ = 160; - const int rightMargin_ = rightLabelWidth_ + 12; - const int topMargin_ = 24; - const int bottomMargin_ = 28; - const int rowHeight_ = 26; - const int selectedRowExtra_ = 70; - const int barHeight_ = 10; - - QRect plotArea() const { - return QRect(leftMargin_, topMargin_, - width() - leftMargin_ - rightMargin_, - height() - topMargin_ - bottomMargin_); - } - - int rowHeightForIndex(int index) const { - if (index < 0 || index >= data_.targets.size()) return rowHeight_; - const TimelineTarget &target = data_.targets.at(index); - if (!selectedObsId_.isEmpty() && target.obsId == selectedObsId_) { - return rowHeight_ + selectedRowExtra_; - } - return rowHeight_; - } - - double timeToX(qint64 t, qint64 t0, qint64 t1, const QRect &plotRect) const { - const double frac = double(t - t0) / double(t1 - t0); - return plotRect.left() + frac * plotRect.width(); - } - - QColor colorForTarget(int index, const QString &obsId) const { - const uint hash = qHash(obsId); - const int base = (static_cast(hash % 360) + index * 137) % 360; - QColor color; - color.setHsv(base, 110, 210); - return color; - } - - QColor displayColorForTarget(const TimelineTarget &target, int index, bool observed) const { - if (!observed) { - return palette().color(QPalette::Disabled, QPalette::Text); - } - if (target.severity == 2) return QColor(220, 70, 70); - if (target.severity == 1) return QColor(255, 165, 0); - return colorForTarget(index, target.obsId); - } - - QRect rowArea(const QRect &plotRect, int index) const { - int y = plotRect.top(); - for (int i = 0; i < index; ++i) { - y += rowHeightForIndex(i); - } - return QRect(plotRect.left(), - y, - plotRect.width(), - rowHeightForIndex(index)); - } - - int rowAt(const QPoint &pos) const { - const QRect plotRect = plotArea(); - if (pos.y() < plotRect.top() || pos.y() > plotRect.bottom()) return -1; - int y = plotRect.top(); - for (int i = 0; i < data_.targets.size(); ++i) { - const int h = rowHeightForIndex(i); - if (pos.y() >= y && pos.y() < y + h) { - return i; - } - y += h; - } - return -1; - } - - void drawTwilightLines(QPainter *painter, const QRect &plotRect, qint64 t0, qint64 t1) { - struct Line { - QDateTime time; - QString label; - }; - QVector lines; - if (data_.twilightEvening16.isValid()) lines.append({data_.twilightEvening16, "Twilight -16"}); - if (data_.twilightEvening12.isValid()) lines.append({data_.twilightEvening12, "Twilight -12"}); - if (data_.twilightMorning12.isValid()) lines.append({data_.twilightMorning12, "Twilight -12"}); - if (data_.twilightMorning16.isValid()) lines.append({data_.twilightMorning16, "Twilight -16"}); - - QPen pen(QColor(60, 60, 60, 160)); - pen.setStyle(Qt::DashLine); - painter->setPen(pen); - for (const Line &line : lines) { - const qint64 t = line.time.toMSecsSinceEpoch(); - if (t < t0 || t > t1) continue; - const double x = timeToX(t, t0, t1, plotRect); - painter->drawLine(QPointF(x, plotRect.top()), QPointF(x, plotRect.bottom())); - painter->drawText(QPointF(x + 4, plotRect.top() + 12), line.label); - } - } - - void drawTimeAxis(QPainter *painter, const QRect &plotRect, qint64 t0, qint64 t1) { - const int tickCount = 6; - painter->setPen(palette().color(QPalette::Text)); - for (int i = 0; i <= tickCount; ++i) { - const double frac = double(i) / tickCount; - const qint64 t = t0 + qint64(frac * (t1 - t0)); - const double x = plotRect.left() + frac * plotRect.width(); - painter->drawLine(QPointF(x, plotRect.bottom()), QPointF(x, plotRect.bottom() + 4)); - painter->drawLine(QPointF(x, plotRect.top()), QPointF(x, plotRect.top() - 4)); - QDateTime dt = QDateTime::fromMSecsSinceEpoch(t, Qt::UTC).toLocalTime(); - const QString label = dt.toString("HH:mm"); - painter->drawText(QPointF(x - 14, plotRect.bottom() + 18), label); - painter->drawText(QPointF(x - 14, plotRect.top() - 6), label); - } - } - - void drawIdleGaps(QPainter *painter, const QRect &plotRect, qint64 t0, qint64 t1) { - if (data_.idleIntervals.isEmpty()) return; - QColor gapColor(220, 50, 50, 40); - painter->setPen(Qt::NoPen); - painter->setBrush(gapColor); - for (const auto &interval : data_.idleIntervals) { - const qint64 s = interval.first.toMSecsSinceEpoch(); - const qint64 e = interval.second.toMSecsSinceEpoch(); - const qint64 gs = std::max(s, t0); - const qint64 ge = std::min(e, t1); - if (ge <= gs) continue; - const double x1 = timeToX(gs, t0, t1, plotRect); - const double x2 = timeToX(ge, t0, t1, plotRect); - painter->drawRect(QRectF(x1, plotRect.top(), x2 - x1, plotRect.height())); - } - } - - void drawExposureBar(QPainter *painter, const QRect &rowRect, const TimelineTarget &target, - qint64 t0, qint64 t1, bool selected, int colorIndex, bool observed) { - if (!observed) return; - QVector> segments = target.segments; - if (segments.isEmpty() && target.startUtc.isValid() && target.endUtc.isValid()) { - segments.append({target.startUtc, target.endUtc}); - } - if (segments.isEmpty()) return; - const QRect plotRect = rowRect; - QColor color = displayColorForTarget(target, colorIndex, observed); - color.setAlpha(selected ? 200 : 150); - painter->setPen(Qt::NoPen); - painter->setBrush(color); - for (const auto &segment : segments) { - const qint64 s = segment.first.toMSecsSinceEpoch(); - const qint64 e = segment.second.toMSecsSinceEpoch(); - if (e <= t0 || s >= t1) continue; - const double x1 = timeToX(std::max(s, t0), t0, t1, plotRect); - const double x2 = timeToX(std::min(e, t1), t0, t1, plotRect); - const double y = rowRect.center().y() - barHeight_ / 2.0; - QRectF bar(x1, y, x2 - x1, barHeight_); - painter->drawRoundedRect(bar, 3, 3); - } - } - - void drawAirmassCurve(QPainter *painter, const QRect &rowRect, const TimelineTarget &target, - qint64 t0, qint64 t1, double minVal, double maxVal, - bool selected, int colorIndex, bool observed) { - if (data_.timesUtc.isEmpty() || target.airmass.isEmpty()) return; - const int n = std::min(data_.timesUtc.size(), target.airmass.size()); - if (n <= 1) return; - QPainterPath path; - bool started = false; - for (int i = 0; i < n; ++i) { - const double am = target.airmass.at(i); - if (!std::isfinite(am) || am > maxVal || am < minVal) { - if (started) started = false; - continue; - } - const qint64 t = data_.timesUtc.at(i).toMSecsSinceEpoch(); - const double x = timeToX(t, t0, t1, rowRect); - const double frac = (am - minVal) / (maxVal - minVal); - const double clamped = std::min(1.0, std::max(0.0, frac)); - const double y = rowRect.top() + clamped * rowRect.height(); - if (!started) { - path.moveTo(x, y); - started = true; - } else { - path.lineTo(x, y); - } - } - if (path.isEmpty()) return; - - QColor lineColor = displayColorForTarget(target, colorIndex, observed); - if (!observed) lineColor = palette().color(QPalette::Disabled, QPalette::Text); - QPen pen(lineColor, selected ? 2.0 : 1.2); - pen.setStyle(Qt::DashLine); - pen.setCapStyle(Qt::RoundCap); - pen.setJoinStyle(Qt::RoundJoin); - painter->setPen(pen); - painter->setBrush(Qt::NoBrush); - painter->drawPath(path); - } - - void drawAirmassCurveLabels(QPainter *painter, const QRect &rowRect, const TimelineTarget &target, - qint64 t0, qint64 t1, double minVal, double maxVal) { - if (data_.timesUtc.isEmpty() || target.airmass.isEmpty()) return; - const int n = std::min(data_.timesUtc.size(), target.airmass.size()); - if (n <= 0) return; - - QVector visibleIdx; - visibleIdx.reserve(n); - for (int i = 0; i < n; ++i) { - const double am = target.airmass.at(i); - if (!std::isfinite(am) || am > maxVal || am < minVal) continue; - visibleIdx.append(i); - } - if (visibleIdx.isEmpty()) return; - - int labelCount = std::min(10, static_cast(visibleIdx.size())); - QFont font = painter->font(); - font.setPointSize(std::max(8, font.pointSize() - 1)); - painter->setFont(font); - QColor labelColor = palette().color(QPalette::Text); - labelColor.setAlpha(170); - painter->setPen(labelColor); - QFontMetrics fm(font); - - // Determine consistent placement (above or below) based on peak location. - double peak = -1.0; - int peakIdx = -1; - for (int i = 0; i < visibleIdx.size(); ++i) { - const double am = target.airmass.at(visibleIdx.at(i)); - if (am > peak) { - peak = am; - peakIdx = visibleIdx.at(i); - } - } - const bool placeAbove = (peakIdx >= 0) ? (peakIdx < n / 2) : true; - - const int labelHeight = fm.height(); - const int minSpacing = std::max(6, labelHeight + 2); - if (labelCount > 1) { - const int labelWidth = fm.horizontalAdvance(QString::number(peak > 0 ? peak : 1.0, 'f', 1)); - const int desiredSpacing = labelWidth + 6; - const int maxLabelsByWidth = std::max(1, rowRect.width() / std::max(1, desiredSpacing)); - labelCount = std::min(labelCount, maxLabelsByWidth); - } - - double lastX = -1e9; - for (int k = 0; k < labelCount; ++k) { - const int idx = visibleIdx.at((k * (visibleIdx.size() - 1)) / std::max(1, labelCount - 1)); - const double am = target.airmass.at(idx); - const qint64 t = data_.timesUtc.at(idx).toMSecsSinceEpoch(); - const double x = timeToX(t, t0, t1, rowRect); - const double frac = (am - minVal) / (maxVal - minVal); - const double clamped = std::min(1.0, std::max(0.0, frac)); - const double y = rowRect.top() + clamped * rowRect.height(); - const QString label = QString::number(am, 'f', 1); - const int labelWidth = fm.horizontalAdvance(label); - if (x - lastX < minSpacing) continue; - lastX = x; - int lx = int(x - labelWidth / 2); - if (lx < rowRect.left() + 2) lx = rowRect.left() + 2; - if (lx + labelWidth > rowRect.right() - 2) lx = rowRect.right() - 2 - labelWidth; - int ly = placeAbove ? int(y - labelHeight - 2) : int(y + 2); - if (placeAbove && ly < rowRect.top() + 2) ly = int(y + 2); - if (!placeAbove && ly + labelHeight > rowRect.bottom() - 2) ly = int(y - labelHeight - 2); - painter->drawText(QRect(lx, ly, labelWidth, labelHeight), - Qt::AlignCenter, label); - } - } - - void drawRowSeparator(QPainter *painter, const QRect &plotRect, const QRect &rowRect, int index) { - if (index >= data_.targets.size() - 1) return; - QColor lineColor(90, 90, 90); - lineColor.setAlpha(35); - QPen pen(lineColor, 1.0); - pen.setStyle(Qt::DashLine); - painter->setPen(pen); - const int y = rowRect.bottom(); - painter->drawLine(plotRect.left(), y, plotRect.right(), y); - } - - void drawRowSeparators(QPainter *painter, const QRect &plotRect) { - for (int i = 0; i < data_.targets.size(); ++i) { - const QRect rowRect = rowArea(plotRect, i); - drawRowSeparator(painter, plotRect, rowRect, i); - } - } - - void drawDropIndicator(QPainter *painter, const QRect &plotRect) { - if (!dragging_ || dragHoverIndex_ < 0 || dragHoverIndex_ >= data_.targets.size()) return; - const QRect rowRect = rowArea(plotRect, dragHoverIndex_); - QColor lineColor(90, 160, 255); - QPen pen(lineColor, 2.0); - painter->setPen(pen); - const int y = dragInsertAbove_ ? rowRect.top() : rowRect.bottom(); - painter->drawLine(plotRect.left(), y, plotRect.right(), y); - } -}; - -class TimelinePanel : public QWidget { - Q_OBJECT -public: - explicit TimelinePanel(QWidget *parent = nullptr) : QWidget(parent) { - QVBoxLayout *layout = new QVBoxLayout(this); - scroll_ = new QScrollArea(this); - scroll_->setWidgetResizable(true); - canvas_ = new TimelineCanvas(this); - scroll_->setWidget(canvas_); - layout->addWidget(scroll_, 1); - - connect(canvas_, &TimelineCanvas::targetSelected, this, &TimelinePanel::targetSelected); - connect(canvas_, &TimelineCanvas::targetReorderRequested, this, &TimelinePanel::targetReorderRequested); - connect(canvas_, &TimelineCanvas::flagClicked, this, &TimelinePanel::flagClicked); - connect(canvas_, &TimelineCanvas::contextMenuRequested, this, &TimelinePanel::contextMenuRequested); - connect(canvas_, &TimelineCanvas::exptimeEditRequested, this, &TimelinePanel::exptimeEditRequested); - } - - void setData(const TimelineData &data) { canvas_->setData(data); } - void clear() { canvas_->clear(); } - void setSelectedObsId(const QString &obsId) { canvas_->setSelectedObsId(obsId); } - -signals: - void targetSelected(const QString &obsId); - void targetReorderRequested(const QString &fromObsId, const QString &toObsId); - void flagClicked(const QString &obsId, const QString &flagText); - void contextMenuRequested(const QString &obsId, const QPoint &globalPos); - void exptimeEditRequested(const QString &obsId); - -private: - QScrollArea *scroll_ = nullptr; - TimelineCanvas *canvas_ = nullptr; -}; - -class MainWindow : public QMainWindow { - Q_OBJECT -public: - MainWindow(QWidget *parent = nullptr) : QMainWindow(parent) { - setWindowTitle("NGPS Target Set Editor"); - QWidget *central = new QWidget(this); - QVBoxLayout *layout = new QVBoxLayout(central); - - QHBoxLayout *topBar = new QHBoxLayout(); - seqStart_ = new QPushButton("Seq Start", central); - seqAbort_ = new QPushButton("Seq Abort", central); - QPushButton *activateSetButton = new QPushButton("Activate target set", central); - QPushButton *importCsvButton = new QPushButton("Import CSV", central); - QPushButton *deleteSetButton = new QPushButton("Delete target set", central); - runOtm_ = new QPushButton("Run OTM", central); - showOtmLog_ = new QPushButton("Show OTM log", central); - QLabel *otmStartLabel = new QLabel("OTM Start UTC:", central); - otmStartEdit_ = new QLineEdit(central); - otmUseNow_ = new QCheckBox("Use current time", central); - QLabel *doTypeLabel = new QLabel("Seq Do:", central); - QToolButton *doAllButton = new QToolButton(central); - QToolButton *doOneButton = new QToolButton(central); - QLabel *acqModeLabel = new QLabel("Acq Mode:", central); - QToolButton *acqMode1Button = new QToolButton(central); - QToolButton *acqMode2Button = new QToolButton(central); - QToolButton *acqMode3Button = new QToolButton(central); - connStatus_ = new QLabel("Not connected", central); - - doAllButton->setText("All"); - doAllButton->setCheckable(true); - doOneButton->setText("One"); - doOneButton->setCheckable(true); - doAllButton->setChecked(true); - - acqMode1Button->setText("1"); - acqMode1Button->setCheckable(true); - acqMode2Button->setText("2"); - acqMode2Button->setCheckable(true); - acqMode3Button->setText("3"); - acqMode3Button->setCheckable(true); - acqMode1Button->setChecked(true); - - QButtonGroup *doTypeGroup = new QButtonGroup(this); - doTypeGroup->setExclusive(true); - doTypeGroup->addButton(doAllButton, 0); - doTypeGroup->addButton(doOneButton, 1); - - QButtonGroup *acqModeGroup = new QButtonGroup(this); - acqModeGroup->setExclusive(true); - acqModeGroup->addButton(acqMode1Button, 1); - acqModeGroup->addButton(acqMode2Button, 2); - acqModeGroup->addButton(acqMode3Button, 3); - - topBar->addWidget(seqStart_); - topBar->addWidget(seqAbort_); - topBar->addWidget(activateSetButton); - topBar->addWidget(importCsvButton); - topBar->addWidget(deleteSetButton); - topBar->addWidget(runOtm_); - topBar->addWidget(showOtmLog_); - topBar->addSpacing(12); - topBar->addWidget(doTypeLabel); - topBar->addWidget(doAllButton); - topBar->addWidget(doOneButton); - topBar->addSpacing(12); - topBar->addWidget(acqModeLabel); - topBar->addWidget(acqMode1Button); - topBar->addWidget(acqMode2Button); - topBar->addWidget(acqMode3Button); - topBar->addStretch(); - topBar->addWidget(connStatus_); - layout->addLayout(topBar); - - QHBoxLayout *otmBar = new QHBoxLayout(); - otmBar->addWidget(otmStartLabel); - otmBar->addWidget(otmStartEdit_); - otmBar->addWidget(otmUseNow_); - otmBar->addStretch(); - layout->addLayout(otmBar); - - otmStartEdit_->setFixedWidth(90); - otmStartEdit_->setPlaceholderText("YYYY-MM-DDTHH:MM:SS.sss"); - - tabs_ = new QTabWidget(central); - setsPanel_ = new TablePanel("Target Sets", tabs_); - setTargetsPanel_ = new TablePanel("Targets (Set View)", tabs_); - timelinePanel_ = new TimelinePanel(tabs_); - - setTargetsPanel_->setSearchColumn("NAME"); - setTargetsPanel_->setOrderByColumn("OBS_ORDER"); - setTargetsPanel_->setSortingEnabled(false); - setTargetsPanel_->setAllowReorder(true); - setTargetsPanel_->setAllowDelete(true); - setTargetsPanel_->setAllowColumnHeaderBulkEdit(true); - setTargetsPanel_->setRowNormalizer(normalizeTargetRow); - setTargetsPanel_->setGroupingEnabled(true); - setTargetsPanel_->setQuickAddEnabled(true); - setTargetsPanel_->setQuickAddInsertAtTop(true); - setTargetsPanel_->setHiddenColumns({"OBSERVATION_ID", "SET_ID", "OBS_ORDER", - "TARGET_NUMBER", "SEQUENCE_NUMBER", "SLITOFFSET", - "OBSMODE"}); - setTargetsPanel_->setColumnAfterRules({{"NEXP", "EXPTIME"}, - {"EXPTIME", "OTMexpt"}, - {"OTMEXPT", "SLITWIDTH"}, - {"SLITWIDTH", "OTMslitwidth"}, - {"AIRMASS_MAX", "MAGNITUDE"}, - {"MAGNITUDE", "MAGFILTER"}}); - setTargetsPanel_->setQuickAddBuilder([this](QVariantMap &values, QSet &nullColumns, - QString *error) -> bool { - Q_UNUSED(nullColumns); - if (!setTargetsPanel_) { - if (error) *error = "Target list not ready."; - return false; - } - const QString setIdStr = setTargetsPanel_->fixedFilterValue(); - bool okSet = false; - const int setId = setIdStr.toInt(&okSet); - if (!okSet || setId <= 0) { - if (error) *error = "Select a target set before adding targets."; - return false; - } - - QSet cols; - for (const ColumnMeta &meta : setTargetsPanel_->columns()) { - cols.insert(meta.name.toUpper()); - } - auto hasCol = [&](const QString &name) { return cols.contains(name.toUpper()); }; - auto setVal = [&](const QString &name, const QVariant &val) { - if (hasCol(name)) values.insert(name, val); - }; - - int nameSeq = 1; - int insertOrder = 1; - QString err; - if (hasCol("OBS_ORDER")) { - if (!dbClient_.nextObsOrderForSet(config_.tableTargets, setId, &nameSeq, &err)) { - nameSeq = 1; - } - } - - if (insertOrder < 1) insertOrder = 1; - const QString name = QString("NewTarget %1").arg(nameSeq); - setVal("SET_ID", setId); - setVal("OBS_ORDER", insertOrder); - setVal("TARGET_NUMBER", 1); - setVal("SEQUENCE_NUMBER", 1); - setVal("STATE", kDefaultTargetState); - setVal("NAME", name); - setVal("RA", "0.0"); - setVal("DECL", "0.0"); - setVal("OFFSET_RA", "0.0"); - setVal("OFFSET_DEC", "0.0"); - setVal("DRA", "0.0"); - setVal("DDEC", "0.0"); - setVal("SLITANGLE", kDefaultSlitangle); - setVal("SLITWIDTH", kDefaultSlitwidth); - setVal("EXPTIME", kDefaultExptime); - setVal("NEXP", "1"); - setVal("POINTMODE", kDefaultPointmode); - setVal("CCDMODE", kDefaultCcdmode); - setVal("AIRMASS_MAX", formatNumber(kDefaultAirmassMax)); - setVal("BINSPAT", QString::number(kDefaultBin)); - setVal("BINSPECT", QString::number(kDefaultBin)); - setVal("CHANNEL", kDefaultChannel); - setVal("MAGNITUDE", formatNumber(kDefaultMagnitude)); - setVal("MAGSYSTEM", kDefaultMagsystem); - setVal("MAGFILTER", kDefaultMagfilter); - setVal("NOTBEFORE", kDefaultNotBefore); - setVal("SRCMODEL", normalizeSrcmodelValue(QString())); - - double low = 0.0; - double high = 0.0; - const auto def = defaultWrangeForChannel(kDefaultChannel); - low = def.first; - high = def.second; - setVal("WRANGE_LOW", formatNumber(low)); - setVal("WRANGE_HIGH", formatNumber(high)); - - double exptimeNumeric = 0.0; - if (extractSetNumeric(kDefaultExptime, &exptimeNumeric)) { - setVal("OTMexpt", formatNumber(exptimeNumeric)); - } - setVal("OTMslitwidth", formatNumber(kDefaultOtmSlitwidth)); - - return true; - }); - - tabs_->addTab(setsPanel_, "Target Sets"); - tabs_->addTab(setTargetsPanel_, "Targets (Set View)"); - tabs_->addTab(timelinePanel_, "Timeline"); - layout->addWidget(tabs_, 5); - - setCentralWidget(central); - - connect(seqStart_, &QPushButton::clicked, this, &MainWindow::seqStart); - connect(seqAbort_, &QPushButton::clicked, this, &MainWindow::seqAbort); - connect(activateSetButton, &QPushButton::clicked, this, &MainWindow::activateSelectedSet); - connect(importCsvButton, &QPushButton::clicked, this, &MainWindow::importTargetListCsv); - connect(deleteSetButton, &QPushButton::clicked, this, &MainWindow::deleteSelectedSet); - connect(runOtm_, &QPushButton::clicked, this, &MainWindow::runOtm); - connect(showOtmLog_, &QPushButton::clicked, this, &MainWindow::showOtmLog); - connect(otmUseNow_, &QCheckBox::toggled, this, &MainWindow::handleOtmUseNowToggle); - connect(otmStartEdit_, &QLineEdit::editingFinished, this, [this]() { - saveOtmStart(); - scheduleAutoOtmRun(); - }); - connect(setsPanel_, &TablePanel::selectionChanged, this, &MainWindow::updateSetViewFromSelection); - connect(setTargetsPanel_, &TablePanel::dataMutated, this, &MainWindow::scheduleAutoOtmRun); - connect(doTypeGroup, QOverload::of(&QButtonGroup::idClicked), this, - [this](int id) { runSeqCommand({"do", id == 0 ? "all" : "one"}); }); - connect(acqModeGroup, QOverload::of(&QButtonGroup::idClicked), this, - [this](int id) { runSeqCommand({"acqmode", QString::number(id)}); }); - - otmAutoTimer_ = new QTimer(this); - otmAutoTimer_->setSingleShot(true); - connect(otmAutoTimer_, &QTimer::timeout, this, &MainWindow::runOtmAuto); - - connect(timelinePanel_, &TimelinePanel::targetSelected, this, [this](const QString &obsId) { - if (!obsId.isEmpty()) { - setTargetsPanel_->selectRowByColumnValue("OBSERVATION_ID", obsId); - } - }); - connect(timelinePanel_, &TimelinePanel::targetReorderRequested, this, - [this](const QString &fromObsId, const QString &toObsId) { - handleTimelineReorder(fromObsId, toObsId); - }); - connect(timelinePanel_, &TimelinePanel::flagClicked, this, - [this](const QString &obsId, const QString &flagText) { - showOtmFlagDetails(obsId, flagText); - }); - connect(timelinePanel_, &TimelinePanel::contextMenuRequested, this, - [this](const QString &obsId, const QPoint &globalPos) { - if (!setTargetsPanel_) return; - setTargetsPanel_->showContextMenuForObsId(obsId, globalPos); - }); - connect(timelinePanel_, &TimelinePanel::exptimeEditRequested, this, - [this](const QString &obsId) { editExptimeForObsId(obsId); }); - - connect(setTargetsPanel_, &TablePanel::selectionChanged, this, [this]() { - const QVariantMap values = setTargetsPanel_->currentRowValues(); - const QString obsId = values.value("OBSERVATION_ID").toString(); - if (!obsId.isEmpty() && timelinePanel_) { - timelinePanel_->setSelectedObsId(obsId); - } - }); - - connectFromConfig(); - } - -protected: - void closeEvent(QCloseEvent *event) override { - closing_ = true; - if (otmAutoTimer_) otmAutoTimer_->stop(); - if (setsPanel_) setsPanel_->persistHeaderState(); - if (setTargetsPanel_) setTargetsPanel_->persistHeaderState(); - for (QProcess *proc : findChildren()) { - proc->disconnect(); - if (proc->state() != QProcess::NotRunning) { - proc->kill(); - proc->waitForFinished(200); - } - } - QMainWindow::closeEvent(event); - } - -private slots: - void connectFromConfig() { - const QString cfgPath = detectDefaultConfigPath(); - if (cfgPath.isEmpty()) { - showWarning(this, "Config", "sequencerd.cfg not found."); - return; - } - configPath_ = cfgPath; - config_ = loadConfigFile(cfgPath); - if (!config_.isComplete()) { - showWarning(this, "Config", "Config file is missing DB settings."); - return; - } - openDatabase(); - } - - void seqStart() { seqStartWithStartupCheck(); } - void seqAbort() { runSeqCommand({"abort"}); } - void activateSelectedSet() { - const QVariantMap values = setsPanel_->currentRowValues(); - if (values.isEmpty()) { - showInfo(this, "Target Sets", "Select a target set first."); - return; - } - const QVariant setName = values.value("SET_NAME"); - const QString setNameText = setName.toString().trimmed(); - if (!setName.isValid() || setNameText.isEmpty()) { - showWarning(this, "Target Sets", "SET_NAME not found."); - return; - } - runSeqCommand({"targetset", setNameText}); - } - - void importTargetListCsv() { - if (!dbClient_.isOpen()) { - showWarning(this, "Import CSV", "Not connected to database."); - return; - } - const QString filePath = QFileDialog::getOpenFileName( - this, "Import Target List CSV", QDir::currentPath(), "CSV Files (*.csv)"); - if (filePath.isEmpty()) return; - - const QString defaultSetName = QFileInfo(filePath).baseName(); - bool ok = false; - QString setName = QInputDialog::getText( - this, "New Target Set", "Target set name:", QLineEdit::Normal, defaultSetName, &ok); - if (!ok) return; - setName = setName.trimmed(); - if (setName.isEmpty()) { - showWarning(this, "Import CSV", "Target set name is required."); - return; - } - - int setId = -1; - QString error; - if (!createTargetSet(setName, &setId, &error)) { - showWarning(this, "Import CSV", error.isEmpty() ? "Failed to create target set." : error); - return; - } - - QList targetColumns; - if (!dbClient_.loadColumns(config_.tableTargets, targetColumns, &error)) { - showWarning(this, "Import CSV", error.isEmpty() ? "Failed to load target columns." : error); - return; - } - - QSet targetColumnNames; - for (const ColumnMeta &meta : targetColumns) { - targetColumnNames.insert(meta.name.toUpper()); - } - - QFile file(filePath); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - showWarning(this, "Import CSV", "Unable to read CSV file."); - return; - } - - QTextStream in(&file); - QString headerLine; - while (!in.atEnd()) { - headerLine = in.readLine(); - if (!headerLine.trimmed().isEmpty()) break; - } - if (headerLine.trimmed().isEmpty()) { - showWarning(this, "Import CSV", "CSV file is empty."); - return; - } - - const QStringList headerFields = parseCsvLine(headerLine); - QHash headerMap; - for (int i = 0; i < headerFields.size(); ++i) { - headerMap.insert(headerFields[i].trimmed().toUpper(), i); - } - - auto getField = [&](const QString &name, const QStringList &fields) -> QString { - const int idx = headerMap.value(name.toUpper(), -1); - if (idx < 0 || idx >= fields.size()) return QString(); - return fields.at(idx).trimmed(); - }; - - int obsOrder = 1; - int inserted = 0; - int rowIndex = 0; - QStringList warnings; - - while (!in.atEnd()) { - const QString line = in.readLine(); - if (line.trimmed().isEmpty()) continue; - ++rowIndex; - const QStringList fields = parseCsvLine(line); - if (fields.size() == 1 && fields.at(0).trimmed().isEmpty()) continue; - - QVariantMap values; - QSet nullColumns; - - const QString name = getField("NAME", fields); - if (name.trimmed().isEmpty()) { - warnings << QString("Skipped row %1: missing NAME").arg(rowIndex); - continue; - } - const bool isCalib = name.toUpper().startsWith("CAL_"); - - if (targetColumnNames.contains("SET_ID")) values.insert("SET_ID", setId); - if (targetColumnNames.contains("OBS_ORDER")) values.insert("OBS_ORDER", obsOrder); - if (targetColumnNames.contains("TARGET_NUMBER")) values.insert("TARGET_NUMBER", 1); - if (targetColumnNames.contains("SEQUENCE_NUMBER")) values.insert("SEQUENCE_NUMBER", 1); - if (targetColumnNames.contains("STATE")) values.insert("STATE", kDefaultTargetState); - if (targetColumnNames.contains("NAME")) values.insert("NAME", name); - - const QString ra = getField("RA", fields); - const QString dec = getField("DECL", fields); - if (!isCalib && (ra.isEmpty() || dec.isEmpty())) { - warnings << QString("Skipped row %1 (%2): missing RA/DECL").arg(rowIndex).arg(name); - continue; - } - if (targetColumnNames.contains("RA") && !ra.isEmpty()) values.insert("RA", ra); - if (targetColumnNames.contains("DECL") && !dec.isEmpty()) values.insert("DECL", dec); - - const QString offsetRa = getField("OFFSET_RA", fields); - const QString offsetDec = getField("OFFSET_DEC", fields); - if (targetColumnNames.contains("OFFSET_RA") && !offsetRa.isEmpty()) values.insert("OFFSET_RA", offsetRa); - if (targetColumnNames.contains("OFFSET_DEC") && !offsetDec.isEmpty()) values.insert("OFFSET_DEC", offsetDec); - - const QString slitangle = getField("SLITANGLE", fields); - const QString slitwidth = getField("SLITWIDTH", fields); - const QString exptime = getField("EXPTIME", fields); - if (targetColumnNames.contains("SLITANGLE")) values.insert("SLITANGLE", slitangle); - if (targetColumnNames.contains("SLITWIDTH")) values.insert("SLITWIDTH", slitwidth); - if (targetColumnNames.contains("EXPTIME")) values.insert("EXPTIME", exptime); - - const QString binspect = getField("BINSPECT", fields); - const QString binspat = getField("BINSPAT", fields); - if (targetColumnNames.contains("BINSPECT")) values.insert("BINSPECT", binspect); - if (targetColumnNames.contains("BINSPAT")) values.insert("BINSPAT", binspat); - - const QString airmassMax = getField("AIRMASS_MAX", fields); - if (targetColumnNames.contains("AIRMASS_MAX")) values.insert("AIRMASS_MAX", airmassMax); - - const QString wrangeLow = getField("WRANGE_LOW", fields); - const QString wrangeHigh = getField("WRANGE_HIGH", fields); - if (targetColumnNames.contains("WRANGE_LOW")) values.insert("WRANGE_LOW", wrangeLow); - if (targetColumnNames.contains("WRANGE_HIGH")) values.insert("WRANGE_HIGH", wrangeHigh); - - const QString channel = getField("CHANNEL", fields); - if (targetColumnNames.contains("CHANNEL")) values.insert("CHANNEL", channel); - - const QString magnitude = getField("MAGNITUDE", fields); - const QString magfilter = getField("MAGFILTER", fields); - const QString magsystem = getField("MAGSYSTEM", fields); - if (targetColumnNames.contains("MAGNITUDE")) values.insert("MAGNITUDE", magnitude); - if (targetColumnNames.contains("MAGFILTER")) values.insert("MAGFILTER", magfilter); - if (targetColumnNames.contains("MAGSYSTEM")) values.insert("MAGSYSTEM", magsystem); - - if (targetColumnNames.contains("POINTMODE")) values.insert("POINTMODE", getField("POINTMODE", fields)); - if (targetColumnNames.contains("CCDMODE")) values.insert("CCDMODE", getField("CCDMODE", fields)); - if (targetColumnNames.contains("NOTBEFORE")) values.insert("NOTBEFORE", getField("NOTBEFORE", fields)); - - QString comment = getField("COMMENT", fields); - const QString priority = getField("PRIORITY", fields); - if (!priority.trimmed().isEmpty()) { - if (!comment.trimmed().isEmpty()) comment += " "; - comment += QString("PRIORITY=%1").arg(priority.trimmed()); - } - if (targetColumnNames.contains("COMMENT") && !comment.isEmpty()) values.insert("COMMENT", comment); - - if (targetColumnNames.contains("OTMSLITWIDTH")) values.insert("OTMslitwidth", QString()); - if (targetColumnNames.contains("OTMEXPT")) values.insert("OTMexpt", QString()); - - normalizeTargetRow(values, nullColumns); - - if (targetColumnNames.contains("OTMSLITWIDTH")) { - if (values.value("OTMslitwidth").toString().trimmed().isEmpty()) { - values.insert("OTMslitwidth", formatNumber(kDefaultOtmSlitwidth)); - } - } - - for (const ColumnMeta &meta : targetColumns) { - if (values.contains(meta.name)) { - const QString text = values.value(meta.name).toString().trimmed(); - if (text.isEmpty() && meta.nullable) { - nullColumns.insert(meta.name); - } - } else if (meta.nullable) { - nullColumns.insert(meta.name); - } - } - - QString rowError; - if (!dbClient_.insertRecord(config_.tableTargets, targetColumns, values, nullColumns, &rowError)) { - warnings << QString("Row %1: %2").arg(rowIndex).arg(rowError.isEmpty() ? "Insert failed" : rowError); - continue; - } - ++inserted; - ++obsOrder; - } - - if (inserted > 0 && targetColumnNames.contains("SET_ID")) { - QVariantMap keyValues; - keyValues.insert("SET_ID", setId); - QVariantMap updates; - updates.insert("NUM_OBSERVATIONS", inserted); - dbClient_.updateColumnsByKey(config_.tableSets, updates, keyValues, nullptr); - } - - setsPanel_->refresh(); - setsPanel_->selectRowByColumnValue("SET_ID", setId); - updateSetViewFromSelection(); - - QString summary = QString("Imported %1 targets into set \"%2\".").arg(inserted).arg(setName); - if (!warnings.isEmpty()) { - summary += "\n\nWarnings:\n" + warnings.join("\n"); - } - showInfo(this, "Import CSV", summary); - scheduleAutoOtmRun(); - } - - void deleteSelectedSet() { - if (!dbClient_.isOpen()) { - showWarning(this, "Delete Target Set", "Not connected to database."); - return; - } - const QVariantMap values = setsPanel_->currentRowValues(); - if (values.isEmpty()) { - showInfo(this, "Delete Target Set", "Select a target set first."); - return; - } - const QVariant setId = values.value("SET_ID"); - const QString setName = values.value("SET_NAME").toString(); - if (!setId.isValid()) { - showWarning(this, "Delete Target Set", "SET_ID not found."); - return; - } - - const QString prompt = QString("Delete target set \"%1\" and all targets in it?") - .arg(setName.isEmpty() ? setId.toString() : setName); - if (QMessageBox::question(this, "Delete Target Set", prompt, - QMessageBox::Yes | QMessageBox::No) != QMessageBox::Yes) { - return; - } - - QString error; - if (!dbClient_.deleteRecordsByColumn(config_.tableTargets, "SET_ID", setId, &error)) { - showWarning(this, "Delete Target Set", error.isEmpty() ? "Failed to delete targets." : error); - return; - } - QVariantMap keyValues; - keyValues.insert("SET_ID", setId); - if (!dbClient_.deleteRecordByKey(config_.tableSets, keyValues, &error)) { - showWarning(this, "Delete Target Set", error.isEmpty() ? "Failed to delete target set." : error); - return; - } - setsPanel_->refresh(); - setTargetsPanel_->clearFixedFilter(); - setTargetsPanel_->refresh(); - if (timelinePanel_) timelinePanel_->clear(); - } - - void showOtmLog() { - if (lastOtmLog_.trimmed().isEmpty()) { - showInfo(this, "OTM Log", "No OTM output captured yet."); - return; - } - showInfo(this, "OTM Log", lastOtmLog_); - } - - void showOtmFlagDetails(const QString &obsId, const QString &flagText) { - if (flagText.trimmed().isEmpty()) return; - QString detail = explainOtmFlags(flagText); - if (detail.isEmpty()) { - detail = QString("Flags: %1").arg(flagText.trimmed()); - } else { - detail = QString("Flags: %1\n\n%2").arg(flagText.trimmed(), detail); - } - if (!obsId.trimmed().isEmpty()) { - detail.prepend(QString("Target: %1\n").arg(obsId.trimmed())); - } - showInfo(this, "OTM Flag Details", detail); - } - - void editExptimeForObsId(const QString &obsId) { - if (obsId.trimmed().isEmpty()) return; - if (!dbClient_.isOpen()) { - showWarning(this, "Edit EXPTIME", "Not connected to database."); - return; - } - if (!setTargetsPanel_) return; - - const bool hasNexp = setTargetsPanel_->hasColumn("NEXP"); - QVariant currentVal = setTargetsPanel_->valueForColumnInRow("OBSERVATION_ID", obsId, "EXPTIME"); - QString currentText = currentVal.toString().trimmed(); - if (currentText.isEmpty()) currentText = kDefaultExptime; - - int currentNexp = 1; - if (hasNexp) { - QVariant nexpVal = setTargetsPanel_->valueForColumnInRow("OBSERVATION_ID", obsId, "NEXP"); - int parsed = 0; - if (parseInt(nexpVal.toString(), &parsed) && parsed > 0) { - currentNexp = parsed; - } - } - - QDialog dialog(this); - dialog.setWindowTitle("Edit Exposure"); - QVBoxLayout *layout = new QVBoxLayout(&dialog); - QFormLayout *form = new QFormLayout(); - QLineEdit *exptimeEdit = new QLineEdit(currentText, &dialog); - form->addRow("EXPTIME (SET or SNR ):", exptimeEdit); - QSpinBox *nexpSpin = nullptr; - if (hasNexp) { - nexpSpin = new QSpinBox(&dialog); - nexpSpin->setRange(1, 9999); - nexpSpin->setValue(currentNexp); - form->addRow("NEXP:", nexpSpin); - } - layout->addLayout(form); - QDialogButtonBox *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dialog); - connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); - connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); - layout->addWidget(buttons); - - if (dialog.exec() != QDialog::Accepted) return; - QString newText = exptimeEdit->text().trimmed(); - if (newText.isEmpty()) return; - int newNexp = currentNexp; - if (hasNexp && nexpSpin) { - newNexp = std::max(1, nexpSpin->value()); - } - - QStringList targetObsIds; - QHash groups = setTargetsPanel_->groupMembersByHeaderObsId(); - if (groups.contains(obsId)) { - targetObsIds = groups.value(obsId); - } else { - for (auto it = groups.begin(); it != groups.end(); ++it) { - if (it.value().contains(obsId)) { - targetObsIds = it.value(); - break; - } - } - } - if (targetObsIds.isEmpty()) targetObsIds << obsId; - - QStringList errors; - for (const QString &memberObsId : targetObsIds) { - QVariant nameVal = setTargetsPanel_->valueForColumnInRow("OBSERVATION_ID", memberObsId, "NAME"); - const QString name = nameVal.toString().trimmed(); - const bool isCalib = name.toUpper().startsWith("CAL_"); - const QString normalized = normalizeExptimeValue(newText, isCalib); - QVariantMap updates; - updates.insert("EXPTIME", normalized); - if (hasNexp) { - updates.insert("NEXP", QString::number(newNexp)); - } - QVariantMap keyValues; - keyValues.insert("OBSERVATION_ID", memberObsId); - QString error; - if (!dbClient_.updateColumnsByKey(config_.tableTargets, updates, keyValues, &error)) { - errors << QString("%1: %2").arg(memberObsId, error.isEmpty() ? "Update failed" : error); - } - } - - if (!errors.isEmpty()) { - showWarning(this, "Edit EXPTIME", errors.join("\n")); - } else { - setTargetsPanel_->refresh(); - scheduleAutoOtmRun(); - } - } - - void scheduleAutoOtmRun() { - if (closing_) return; - if (!dbClient_.isOpen()) return; - if (otmRunning_) { - otmAutoPending_ = true; - return; - } - if (!otmAutoTimer_) return; - otmAutoTimer_->start(500); - } - - void runOtm() { runOtmInternal(true); } - - void runOtmAuto() { runOtmInternal(false); } - - void runOtmInternal(bool showDialog) { - if (closing_) return; - const bool quiet = !showDialog; - if (!dbClient_.isOpen()) { - if (!quiet) showWarning(this, "Run OTM", "Not connected to database."); - return; - } - - const QVariantMap setValues = setsPanel_->currentRowValues(); - if (setValues.isEmpty()) { - if (!quiet) showInfo(this, "Run OTM", "Select a target set first."); - return; - } - - const QVariant setIdValue = setValues.value("SET_ID"); - bool ok = false; - const int setId = setIdValue.toInt(&ok); - if (!ok) { - if (!quiet) showWarning(this, "Run OTM", "SET_ID not found."); - return; - } - - if (ngpsRoot_.isEmpty()) { - if (!quiet) showWarning(this, "Run OTM", "NGPS root not detected."); - return; - } - - const QString scriptPath = QDir(ngpsRoot_).filePath("Python/OTM/OTM.py"); - if (!QFile::exists(scriptPath)) { - if (!quiet) showWarning(this, "Run OTM", QString("OTM script not found: %1").arg(scriptPath)); - return; - } - - if (otmRunning_) { - otmAutoPending_ = true; - return; - } - - OtmSettings settings = loadOtmSettings(); - if (showDialog) { - OtmSettingsDialog dialog(settings, this); - if (dialog.exec() != QDialog::Accepted) { - return; - } - settings = dialog.settings(); - saveOtmSettings(settings); - } - - QString startUtc = otmStartEdit_ ? otmStartEdit_->text().trimmed() : QString(); - if (otmUseNow_ && otmUseNow_->isChecked()) { - startUtc = currentUtcString(); - if (otmStartEdit_) { - otmStartEdit_->setText(startUtc); - } - } - if (startUtc.isEmpty()) { - startUtc = estimateTwilightUtc(); - if (startUtc.isEmpty()) startUtc = currentUtcString(); - if (otmStartEdit_) { - otmStartEdit_->setText(startUtc); - } - } - startUtc = normalizeOtmStartText(startUtc, quiet); - if (otmStartEdit_) saveOtmStart(); - - QList targetColumns; - QString error; - if (!dbClient_.loadColumns(config_.tableTargets, targetColumns, &error)) { - if (!quiet) showWarning(this, "Run OTM", error.isEmpty() ? "Failed to load target columns." : error); - return; - } - - QList> rows; - if (!dbClient_.fetchRows(config_.tableTargets, targetColumns, - "SET_ID", QString::number(setId), - "", "", "OBS_ORDER", - rows, &error)) { - if (!quiet) showWarning(this, "Run OTM", error.isEmpty() ? "Failed to load targets." : error); - return; - } - - if (rows.isEmpty()) { - if (!quiet) showInfo(this, "Run OTM", "No targets found in the selected set."); - return; - } - - QSet targetColumnNames; - for (int i = 0; i < targetColumns.size(); ++i) { - targetColumnNames.insert(targetColumns[i].name.toUpper()); - } - - QSet manualUngroupObsIds; - QHash panelGroups; - if (setTargetsPanel_) { - manualUngroupObsIds = setTargetsPanel_->ungroupedObsIds(); - panelGroups = setTargetsPanel_->groupMembersByHeaderObsId(); - } - QHash obsIdToHeader; - for (auto it = panelGroups.begin(); it != panelGroups.end(); ++it) { - const QString header = it.key(); - for (const QString &member : it.value()) { - if (!member.isEmpty()) obsIdToHeader.insert(member, header); - } - if (!header.isEmpty()) obsIdToHeader.insert(header, header); - } - - const bool includeSrcmodel = targetColumnNames.contains("SRCMODEL"); - const bool includeNexp = targetColumnNames.contains("NEXP"); - - QStringList header; - header << "OBSERVATION_ID" - << "name" << "RA" << "DECL" - << "slitangle" << "slitwidth"; - if (includeNexp) header << "NEXP"; - header << "exptime" - << "notbefore" << "pointmode" << "ccdmode" - << "airmass_max" << "binspat" << "binspect" - << "channel" << "wrange" << "mag" << "magsystem" << "magfilter"; - if (includeSrcmodel) header << "srcmodel"; - - struct RowRecord { - int rowIndex = 0; - QVariantMap values; - QSet nullColumns; - QString obsId; - QString name; - bool isCalib = false; - QString groupKey; - bool isScience = false; - }; - - struct GroupInfo { - QString scienceObsId; - QStringList members; - }; - - QStringList inputWarnings; - QStringList coordDiagnostics; - QHash oldSlitwidthByObsId; - QHash obsOrderByObsId; - QVector records; - QHash groups; - - int rowIndex = 0; - for (const QList &row : rows) { - ++rowIndex; - QVariantMap values; - QSet nullColumns; - for (int i = 0; i < targetColumns.size(); ++i) { - if (i >= row.size()) continue; - const QVariant value = row.at(i); - if (value.isValid() && !value.isNull()) { - values.insert(targetColumns[i].name, value); - } else { - nullColumns.insert(targetColumns[i].name); - } - } - - normalizeTargetRow(values, nullColumns); - - const QString obsId = valueToStringCaseInsensitive(values, "OBSERVATION_ID"); - if (obsId.isEmpty()) { - inputWarnings << QString("Row %1: missing OBSERVATION_ID").arg(rowIndex); - continue; - } - - int obsOrder = 0; - bool orderOk = false; - obsOrder = valueToStringCaseInsensitive(values, "OBS_ORDER").toInt(&orderOk); - if (orderOk) { - obsOrderByObsId.insert(obsId, obsOrder); - } - - const QString name = valueToStringCaseInsensitive(values, "NAME"); - if (name.isEmpty()) { - inputWarnings << QString("Row %1: missing NAME").arg(rowIndex); - continue; - } - - const bool isCalib = name.toUpper().startsWith("CAL_"); - const QString ra = valueToStringCaseInsensitive(values, "RA"); - const QString dec = valueToStringCaseInsensitive(values, "DECL"); - if (!isCalib && (ra.isEmpty() || dec.isEmpty())) { - inputWarnings << QString("Row %1 (%2): missing RA/DECL").arg(rowIndex).arg(name); - continue; - } - - const QString groupKey = obsIdToHeader.value(obsId, obsId); - - { - double raDeg = 0.0; - double decDeg = 0.0; - if (computeScienceCoordDegreesProjected(values, &raDeg, &decDeg)) { - bool hasOffsetRa = false; - bool hasOffsetDec = false; - const double offsetRa = offsetArcsecFromValues(values, {"OFFSET_RA", "DRA"}, &hasOffsetRa); - const double offsetDec = offsetArcsecFromValues(values, {"OFFSET_DEC", "DDEC"}, &hasOffsetDec); - QString key = groupKey; - coordDiagnostics << QString("%1\t%2\tRA=%3\tDEC=%4\tDRA=%5\tDDEC=%6\tKEY=%7") - .arg(obsId, - name, - QString::number(raDeg, 'f', 6), - QString::number(decDeg, 'f', 6), - QString::number(offsetRa, 'f', 3), - QString::number(offsetDec, 'f', 3), - key); - } else { - coordDiagnostics << QString("%1\t%2\tRA/DEC parse failed").arg(obsId, name); - } - } - - const bool isScience = (obsId == groupKey); - - GroupInfo &group = groups[groupKey]; - group.members.append(obsId); - if (isScience && group.scienceObsId.isEmpty()) { - group.scienceObsId = obsId; - } - - RowRecord record; - record.rowIndex = rowIndex; - record.values = values; - record.nullColumns = nullColumns; - record.obsId = obsId; - record.name = name; - record.isCalib = isCalib; - record.groupKey = groupKey; - record.isScience = isScience; - records.append(record); - - const QVariant oldSlit = values.value(findKeyCaseInsensitive(values, "OTMslitwidth")); - if (oldSlit.isValid() && !oldSlit.isNull()) { - oldSlitwidthByObsId.insert(obsId, oldSlit); - } - } - - QHash membersByScienceObsId; - for (auto it = groups.begin(); it != groups.end(); ++it) { - if (it->members.isEmpty()) continue; - if (it->scienceObsId.isEmpty()) { - it->scienceObsId = it->members.first(); - inputWarnings << QString("Group %1: missing header row, using OBSERVATION_ID %2") - .arg(it.key(), it->scienceObsId); - } - membersByScienceObsId.insert(it->scienceObsId, it->members); - } - - QStringList lines; - lines << header.join(","); - for (const RowRecord &record : records) { - const GroupInfo group = groups.value(record.groupKey); - if (record.obsId != group.scienceObsId) { - continue; - } - - QVariantMap values = record.values; - QSet nullColumns = record.nullColumns; - const QString obsId = record.obsId; - const QString name = record.name; - const bool isCalib = record.isCalib; - - QString ra = valueToStringCaseInsensitive(values, "RA"); - QString dec = valueToStringCaseInsensitive(values, "DECL"); - if (!isCalib && (ra.isEmpty() || dec.isEmpty())) { - inputWarnings << QString("Row %1 (%2): missing RA/DECL").arg(record.rowIndex).arg(name); - continue; - } - - QString channel = valueToStringCaseInsensitive(values, "CHANNEL"); - if (channel.isEmpty()) channel = kDefaultChannel; - - double low = 0.0; - double high = 0.0; - const bool lowOk = parseDouble(valueToStringCaseInsensitive(values, "WRANGE_LOW"), &low); - const bool highOk = parseDouble(valueToStringCaseInsensitive(values, "WRANGE_HIGH"), &high); - if (!lowOk || !highOk || high <= low) { - const auto def = defaultWrangeForChannel(channel); - low = def.first; - high = def.second; - } - const QString wrange = QString("%1 %2").arg(formatNumber(low), formatNumber(high)); - - QString slitangle = valueToStringCaseInsensitive(values, "SLITANGLE"); - if (slitangle.isEmpty()) slitangle = kDefaultSlitangle; - QString slitwidth = valueToStringCaseInsensitive(values, "SLITWIDTH"); - if (slitwidth.isEmpty()) slitwidth = kDefaultSlitwidth; - QString exptime = valueToStringCaseInsensitive(values, "EXPTIME"); - if (exptime.isEmpty()) exptime = kDefaultExptime; - int nexp = 1; - if (includeNexp) { - int parsed = 0; - if (parseInt(valueToStringCaseInsensitive(values, "NEXP"), &parsed) && parsed > 0) { - nexp = parsed; - } - } - QString notbefore = valueToStringCaseInsensitive(values, "NOTBEFORE"); - if (notbefore.isEmpty()) notbefore = kDefaultNotBefore; - QString pointmode = valueToStringCaseInsensitive(values, "POINTMODE"); - if (pointmode.isEmpty()) pointmode = kDefaultPointmode; - QString ccdmode = valueToStringCaseInsensitive(values, "CCDMODE"); - if (ccdmode.isEmpty()) ccdmode = kDefaultCcdmode; - QString airmassMax = valueToStringCaseInsensitive(values, "AIRMASS_MAX"); - if (airmassMax.isEmpty()) airmassMax = formatNumber(settings.airmassMax); - QString binspat = valueToStringCaseInsensitive(values, "BINSPAT"); - if (binspat.isEmpty()) binspat = QString::number(kDefaultBin); - QString binspect = valueToStringCaseInsensitive(values, "BINSPECT"); - if (binspect.isEmpty()) binspect = QString::number(kDefaultBin); - QString mag = valueToStringCaseInsensitive(values, "MAGNITUDE"); - if (mag.isEmpty()) mag = formatNumber(kDefaultMagnitude); - QString magsystem = valueToStringCaseInsensitive(values, "MAGSYSTEM"); - if (magsystem.isEmpty()) magsystem = kDefaultMagsystem; - QString magfilter = valueToStringCaseInsensitive(values, "MAGFILTER"); - if (magfilter.isEmpty()) magfilter = kDefaultMagfilter; - QString srcmodel = valueToStringCaseInsensitive(values, "SRCMODEL"); - srcmodel = normalizeSrcmodelValue(srcmodel); - - QStringList fields; - fields << obsId << name << ra << dec - << slitangle << slitwidth; - if (includeNexp) fields << QString::number(nexp); - fields << exptime - << notbefore << pointmode << ccdmode - << airmassMax << binspat << binspect - << channel << wrange << mag << magsystem << magfilter; - if (includeSrcmodel) fields << srcmodel; - - for (QString &field : fields) { - field = csvEscape(field); - } - for (int rep = 0; rep < nexp; ++rep) { - lines << fields.join(","); - } - } - - if (lines.size() <= 1) { - if (!quiet) showInfo(this, "Run OTM", "No valid targets to send to OTM."); - return; - } - - const QString timestamp = QDateTime::currentDateTimeUtc().toString("yyyyMMdd_HHmmss_zzz"); - const QString inputPath = QDir::temp().filePath( - QString("ngps_otm_input_%1_%2.csv").arg(setId).arg(timestamp)); - const QString outputPath = QDir::temp().filePath( - QString("ngps_otm_output_%1_%2.csv").arg(setId).arg(timestamp)); - - QFile inputFile(inputPath); - if (!inputFile.open(QIODevice::WriteOnly | QIODevice::Text)) { - if (!quiet) showWarning(this, "Run OTM", "Unable to write OTM input file."); - return; - } - QTextStream out(&inputFile); - for (const QString &line : lines) { - out << line << "\n"; - } - inputFile.close(); - - if (runOtm_) runOtm_->setEnabled(false); - otmRunning_ = true; - - struct OtmRunContext { - int setId = -1; - QString setName; - QString inputPath; - QString outputPath; - QString timelinePath; - QString timestamp; - double airmassMax = 0.0; - QHash oldSlitwidth; - QHash membersByScienceObsId; - QSet targetColumnNames; - QStringList inputWarnings; - QHash obsOrderByObsId; - QString startUtc; - QStringList coordDiagnostics; - QSet manualUngroupObsIds; - }; - auto context = std::make_shared(); - context->setId = setId; - context->setName = setValues.value("SET_NAME").toString(); - context->inputPath = inputPath; - context->outputPath = outputPath; - context->timestamp = timestamp; - context->oldSlitwidth = oldSlitwidthByObsId; - context->membersByScienceObsId = membersByScienceObsId; - context->targetColumnNames = targetColumnNames; - context->inputWarnings = inputWarnings; - context->obsOrderByObsId = obsOrderByObsId; - context->startUtc = startUtc; - context->airmassMax = settings.airmassMax; - context->coordDiagnostics = coordDiagnostics; - context->manualUngroupObsIds = manualUngroupObsIds; - - const QString pythonCmd = resolveOtmPython(); - QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); - if (env.contains("PYTHONHOME")) env.remove("PYTHONHOME"); - if (env.contains("PYTHONPATH")) env.remove("PYTHONPATH"); - const QString addPath = QDir(ngpsRoot_).filePath("Python"); - env.insert("PYTHONPATH", addPath); - env.insert("PYTHONNOUSERSITE", "1"); - - QProcess *proc = new QProcess(this); - proc->setProcessEnvironment(env); - proc->setWorkingDirectory(ngpsRoot_); - - QStringList args; - args << scriptPath - << inputPath - << startUtc - << "-seeing" << formatNumber(settings.seeingFwhm) << formatNumber(settings.seeingPivot) - << "-airmass_max" << formatNumber(settings.airmassMax) - << "-out" << outputPath; - if (!settings.useSkySim) args << "-noskysim"; - - auto output = std::make_shared(); - statusBar()->showMessage(QString("Running: %1 %2").arg(pythonCmd, args.join(' ')), 5000); - auto withDiagnostics = [context](const QString &base) { - QString log = base; - if (!context->coordDiagnostics.isEmpty()) { - log += "\n\nFinal coordinates after offsets (deg):\n"; - log += context->coordDiagnostics.join("\n"); - } - return log; - }; - connect(proc, &QProcess::readyReadStandardOutput, this, [proc, output]() { - *output += QString::fromUtf8(proc->readAllStandardOutput()); - }); - connect(proc, &QProcess::readyReadStandardError, this, [proc, output]() { - *output += QString::fromUtf8(proc->readAllStandardError()); - }); - connect(proc, &QProcess::errorOccurred, this, - [this, proc, output, context, quiet, withDiagnostics](QProcess::ProcessError) { - if (runOtm_) runOtm_->setEnabled(true); - otmRunning_ = false; - const bool rerun = otmAutoPending_; - otmAutoPending_ = false; - lastOtmLog_ = withDiagnostics(*output); - QString detail = *output; - if (detail.trimmed().isEmpty()) detail = "Failed to start OTM process."; - if (!quiet) { - showWarning(this, "Run OTM", detail); - } else { - statusBar()->showMessage("OTM failed to start. See OTM log.", 8000); - } - QFile::remove(context->inputPath); - QFile::remove(context->outputPath); - proc->deleteLater(); - if (rerun) scheduleAutoOtmRun(); - }); - connect(proc, QOverload::of(&QProcess::finished), this, - [this, proc, output, context, pythonCmd, env, quiet, withDiagnostics](int code, QProcess::ExitStatus status) { - if (runOtm_) runOtm_->setEnabled(true); - otmRunning_ = false; - const bool rerun = otmAutoPending_; - otmAutoPending_ = false; - lastOtmLog_ = withDiagnostics(*output); - auto cleanupFiles = [context]() { - QFile::remove(context->inputPath); - QFile::remove(context->outputPath); - if (!context->timelinePath.isEmpty()) { - QFile::remove(context->timelinePath); - } - }; - - const QString msg = QString("OTM exit %1 (%2)") - .arg(code) - .arg(status == QProcess::NormalExit ? "normal" : "crash"); - statusBar()->showMessage(msg, 5000); - - if (status != QProcess::NormalExit || code != 0) { - QString detail = *output; - if (detail.trimmed().isEmpty()) detail = msg; - if (!quiet) { - showWarning(this, "Run OTM", detail); - } else { - statusBar()->showMessage("OTM failed. See OTM log.", 8000); - } - cleanupFiles(); - proc->deleteLater(); - if (rerun) scheduleAutoOtmRun(); - return; - } - - QFile outFile(context->outputPath); - if (!outFile.open(QIODevice::ReadOnly | QIODevice::Text)) { - if (!quiet) { - showWarning(this, "Run OTM", "Unable to read OTM output file."); - } else { - statusBar()->showMessage("OTM output missing.", 8000); - } - cleanupFiles(); - proc->deleteLater(); - return; - } - - QTextStream in(&outFile); - QString headerLine; - while (!in.atEnd()) { - headerLine = in.readLine(); - if (!headerLine.trimmed().isEmpty()) break; - } - if (headerLine.trimmed().isEmpty()) { - if (!quiet) { - showWarning(this, "Run OTM", "OTM output file is empty."); - } else { - statusBar()->showMessage("OTM output empty.", 8000); - } - cleanupFiles(); - proc->deleteLater(); - return; - } - - const QStringList headers = parseCsvLine(headerLine); - QHash headerMap; - for (int i = 0; i < headers.size(); ++i) { - headerMap.insert(headers[i].trimmed().toUpper(), i); - } - - auto getField = [&](const QString &name, const QStringList &fields) -> QString { - const int idx = headerMap.value(name.toUpper(), -1); - if (idx < 0 || idx >= fields.size()) return QString(); - return fields.at(idx).trimmed(); - }; - - auto addUpdate = [&](QVariantMap &updates, - const QStringList &fields, - const QString &outCol, - const QString &dbCol, - bool isTimestamp, - bool allowEmptyString, - bool skipIfEmpty) { - if (!context->targetColumnNames.contains(dbCol.toUpper())) return; - QString raw = getField(outCol, fields); - if (isTimestamp) { - raw = normalizeOtmTimestamp(raw); - } else { - raw = raw.trimmed(); - } - if (raw.compare("None", Qt::CaseInsensitive) == 0) { - raw.clear(); - } - if (raw.isEmpty()) { - if (skipIfEmpty) { - return; - } - if (allowEmptyString) { - updates.insert(dbCol, QString()); - } else { - updates.insert(dbCol, QVariant()); - } - } else { - updates.insert(dbCol, raw); - } - }; - - struct AggUpdates { - QVariantMap updates; - QDateTime startMin; - QDateTime endMax; - QString startStr; - QString endStr; - bool hasStart = false; - bool hasEnd = false; - }; - - int rowIndex = 0; - QStringList warnings = context->inputWarnings; - QHash aggByObsId; - - while (!in.atEnd()) { - const QString line = in.readLine(); - if (line.trimmed().isEmpty()) continue; - ++rowIndex; - const QStringList fields = parseCsvLine(line); - if (fields.size() == 1 && fields.at(0).trimmed().isEmpty()) continue; - - const QString obsId = getField("OBSERVATION_ID", fields); - if (obsId.isEmpty()) { - warnings << QString("Output row %1: missing OBSERVATION_ID").arg(rowIndex); - continue; - } - - QVariantMap updates; - addUpdate(updates, fields, "OTMstart", "OTMexp_start", true, false, false); - addUpdate(updates, fields, "OTMend", "OTMexp_end", true, false, false); - addUpdate(updates, fields, "OTMexptime", "OTMexpt", false, false, false); - addUpdate(updates, fields, "OTMslitwidth", "OTMslitwidth", false, false, true); - addUpdate(updates, fields, "OTMpa", "OTMpa", false, false, false); - addUpdate(updates, fields, "OTMslitangle", "OTMslitangle", false, false, false); - addUpdate(updates, fields, "OTMcass", "OTMcass", false, false, false); - addUpdate(updates, fields, "OTMwait", "OTMwait", false, false, false); - addUpdate(updates, fields, "OTMflag", "OTMflag", false, true, false); - addUpdate(updates, fields, "OTMlast", "OTMlast", false, true, false); - addUpdate(updates, fields, "OTMslewgo", "OTMslewgo", true, false, false); - addUpdate(updates, fields, "OTMslew", "OTMslew", false, false, false); - addUpdate(updates, fields, "OTMdead", "OTMdead", false, false, false); - addUpdate(updates, fields, "OTMairmass_start", "OTMairmass_start", false, false, false); - addUpdate(updates, fields, "OTMairmass_end", "OTMairmass_end", false, false, false); - addUpdate(updates, fields, "OTMsky", "OTMsky", false, false, false); - addUpdate(updates, fields, "OTMmoon", "OTMmoon", false, false, false); - addUpdate(updates, fields, "OTMSNR", "OTMSNR", false, false, false); - addUpdate(updates, fields, "OTMres", "OTMres", false, false, false); - addUpdate(updates, fields, "OTMseeing", "OTMseeing", false, false, false); - if (updates.isEmpty()) continue; - - AggUpdates &agg = aggByObsId[obsId]; - agg.updates = updates; - - const QString startStr = updates.value("OTMexp_start").toString().trimmed(); - if (!startStr.isEmpty()) { - const QDateTime dt = parseUtcIso(startStr); - if (dt.isValid() && (!agg.hasStart || dt < agg.startMin)) { - agg.startMin = dt; - agg.startStr = startStr; - agg.hasStart = true; - } - } - const QString endStr = updates.value("OTMexp_end").toString().trimmed(); - if (!endStr.isEmpty()) { - const QDateTime dt = parseUtcIso(endStr); - if (dt.isValid() && (!agg.hasEnd || dt > agg.endMax)) { - agg.endMax = dt; - agg.endStr = endStr; - agg.hasEnd = true; - } - } - } - - int updated = 0; - for (auto it = aggByObsId.begin(); it != aggByObsId.end(); ++it) { - const QString obsId = it.key(); - AggUpdates agg = it.value(); - if (agg.hasStart) agg.updates.insert("OTMexp_start", agg.startStr); - if (agg.hasEnd) agg.updates.insert("OTMexp_end", agg.endStr); - - QVariantMap keyValues; - QStringList members = context->membersByScienceObsId.value(obsId); - if (members.isEmpty()) { - members << obsId; - } - for (const QString &memberObsId : members) { - keyValues.clear(); - keyValues.insert("OBSERVATION_ID", memberObsId); - if (context->oldSlitwidth.contains(memberObsId) && - context->targetColumnNames.contains("OTMSLITWIDTH")) { - keyValues.insert("OTMslitwidth", context->oldSlitwidth.value(memberObsId)); - } - - QString rowError; - if (!dbClient_.updateColumnsByKey(config_.tableTargets, agg.updates, keyValues, &rowError)) { - warnings << QString("Output (OBSERVATION_ID %1): %2") - .arg(memberObsId) - .arg(rowError.isEmpty() ? "Update failed" : rowError); - continue; - } - ++updated; - } - } - - setTargetsPanel_->refresh(); - auto finishSummary = [this, context, updated, warnings, quiet]() { - if (!quiet) { - QString summary = QString("OTM updated %1 targets.").arg(updated); - if (!context->setName.trimmed().isEmpty()) { - summary = QString("OTM updated %1 targets in set \"%2\".") - .arg(updated) - .arg(context->setName.trimmed()); - } - if (!warnings.isEmpty()) { - summary += "\n\nWarnings:\n" + warnings.join("\n"); - } - showInfo(this, "Run OTM", summary); - } else { - statusBar()->showMessage(QString("OTM updated %1 targets.").arg(updated), 5000); - } - }; - - auto runTimeline = [this, context, pythonCmd, env, quiet, cleanupFiles]() { - if (!timelinePanel_) { - cleanupFiles(); - return; - } - const QString timelineScript = QDir(ngpsRoot_).filePath("Python/OTM/otm_timeline.py"); - if (!QFile::exists(timelineScript)) { - if (!quiet) { - showWarning(this, "Run OTM", QString("Timeline script not found: %1") - .arg(timelineScript)); - } else { - statusBar()->showMessage("Timeline script missing.", 8000); - } - cleanupFiles(); - return; - } - - context->timelinePath = QDir::temp().filePath( - QString("ngps_otm_timeline_%1_%2.json").arg(context->setId).arg(context->timestamp)); - - QProcess *timelineProc = new QProcess(this); - timelineProc->setProcessEnvironment(env); - if (!ngpsRoot_.isEmpty()) timelineProc->setWorkingDirectory(ngpsRoot_); - - QStringList targs; - targs << timelineScript - << "--input" << context->inputPath - << "--output" << context->outputPath - << "--json" << context->timelinePath; - if (!context->startUtc.trimmed().isEmpty()) { - targs << "--start" << context->startUtc.trimmed(); - } - - auto timelineOutput = std::make_shared(); - connect(timelineProc, &QProcess::readyReadStandardOutput, this, - [timelineProc, timelineOutput]() { - *timelineOutput += QString::fromUtf8(timelineProc->readAllStandardOutput()); - }); - connect(timelineProc, &QProcess::readyReadStandardError, this, - [timelineProc, timelineOutput]() { - *timelineOutput += QString::fromUtf8(timelineProc->readAllStandardError()); - }); - connect(timelineProc, QOverload::of(&QProcess::finished), this, - [this, timelineProc, timelineOutput, context, quiet, cleanupFiles](int code, - QProcess::ExitStatus status) { - if (status != QProcess::NormalExit || code != 0) { - if (!quiet) { - showWarning(this, "Run OTM", "Timeline generation failed:\n" + *timelineOutput); - } else { - statusBar()->showMessage("Timeline generation failed.", 8000); - } - timelineProc->deleteLater(); - cleanupFiles(); - return; - } - - TimelineData data; - QString parseError; - if (!loadTimelineJson(context->timelinePath, &data, &parseError)) { - if (!quiet) { - showWarning(this, "Run OTM", "Failed to load timeline data:\n" + parseError); - } else { - statusBar()->showMessage("Timeline data unreadable.", 8000); - } - timelineProc->deleteLater(); - cleanupFiles(); - return; - } - - if (!context->obsOrderByObsId.isEmpty() && !data.targets.isEmpty()) { - QVector> order; - order.reserve(data.targets.size()); - for (const TimelineTarget &target : data.targets) { - const int raw = context->obsOrderByObsId.value( - target.obsId, std::numeric_limits::max()); - order.append({raw, target.obsId}); - } - std::sort(order.begin(), order.end(), - [](const auto &a, const auto &b) { - if (a.first != b.first) return a.first < b.first; - return a.second < b.second; - }); - QHash seqByObsId; - int seq = 1; - for (const auto &entry : order) { - if (!seqByObsId.contains(entry.second)) { - seqByObsId.insert(entry.second, seq++); - } - } - for (TimelineTarget &target : data.targets) { - if (seqByObsId.contains(target.obsId)) { - target.obsOrder = seqByObsId.value(target.obsId); - } - } - } - - if (!context->obsOrderByObsId.isEmpty()) { - for (TimelineTarget &target : data.targets) { - if (context->obsOrderByObsId.contains(target.obsId)) { - if (target.obsOrder <= 0) { - target.obsOrder = context->obsOrderByObsId.value(target.obsId); - } - } - } - std::stable_sort(data.targets.begin(), data.targets.end(), - [](const TimelineTarget &a, const TimelineTarget &b) { - if (a.obsOrder > 0 && b.obsOrder > 0) { - return a.obsOrder < b.obsOrder; - } - if (a.obsOrder > 0 || b.obsOrder > 0) { - return a.obsOrder > 0; - } - if (a.startUtc.isValid() && b.startUtc.isValid()) { - return a.startUtc < b.startUtc; - } - return a.name < b.name; - }); - } - - data.airmassLimit = context->airmassMax; - timelinePanel_->setData(data); - const QVariantMap current = setTargetsPanel_->currentRowValues(); - const QString obsId = current.value("OBSERVATION_ID").toString(); - if (!obsId.isEmpty()) { - timelinePanel_->setSelectedObsId(obsId); - } - - timelineProc->deleteLater(); - cleanupFiles(); - }); - - timelineProc->start(pythonCmd, targs); - }; - - finishSummary(); - runTimeline(); - proc->deleteLater(); - if (rerun) scheduleAutoOtmRun(); - }); - - proc->start(pythonCmd, args); - } - - void updateSetViewFromSelection() { - const QVariantMap values = setsPanel_->currentRowValues(); - const QVariant setId = values.value("SET_ID"); - if (!setId.isValid()) { - setTargetsPanel_->clearFixedFilter(); - setTargetsPanel_->refresh(); - if (timelinePanel_) timelinePanel_->clear(); - return; - } - setTargetsPanel_->setFixedFilter("SET_ID", setId.toString()); - setTargetsPanel_->refresh(); - if (timelinePanel_) timelinePanel_->clear(); - scheduleAutoOtmRun(); - } - - void handleTimelineReorder(const QString &fromObsId, const QString &toObsId) { - if (fromObsId.isEmpty()) return; - if (!dbClient_.isOpen()) { - statusBar()->showMessage("Reorder failed: not connected.", 5000); - return; - } - - QString error; - if (toObsId.isEmpty()) { - if (!setTargetsPanel_->moveGroupToTopObsId(fromObsId, &error)) { - statusBar()->showMessage(error.isEmpty() ? "Reorder failed." : error, 5000); - return; - } - } else { - if (!setTargetsPanel_->moveGroupAfterObsId(fromObsId, toObsId, &error)) { - statusBar()->showMessage(error.isEmpty() ? "Reorder failed." : error, 5000); - return; - } - } - - scheduleAutoOtmRun(); - } - -private: - struct SeqProcessConfig { - QString cmd; - QProcessEnvironment env; - QString workDir; - }; - - SeqProcessConfig seqProcessConfig() const { - QSettings settings(kSettingsOrg, kSettingsApp); - QString cmd = settings.value("seqCommand").toString(); - if (cmd.isEmpty()) { - if (!ngpsRoot_.isEmpty()) { - const QString candidate = QDir(ngpsRoot_).filePath("run/seq"); - if (QFile::exists(candidate)) { - cmd = candidate; - } - } - if (cmd.isEmpty()) cmd = "seq"; - } - - QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); - if (!seqConfigPath_.isEmpty()) { - env.insert("SEQUENCERD_CONFIG", seqConfigPath_); - } - if (!ngpsRoot_.isEmpty()) { - env.insert("NGPS_ROOT", ngpsRoot_); - } - - SeqProcessConfig cfg; - cfg.cmd = cmd; - cfg.env = env; - cfg.workDir = ngpsRoot_; - return cfg; - } - - static bool outputHasState(const QString &output, const QString &state) { - const QString pattern = QString("\\b%1\\b").arg(QRegularExpression::escape(state)); - const QRegularExpression re(pattern, QRegularExpression::CaseInsensitiveOption); - return re.match(output).hasMatch(); - } - - bool createTargetSet(const QString &setName, int *setIdOut, QString *error) { - QList setColumns; - if (!dbClient_.loadColumns(config_.tableSets, setColumns, error)) { - return false; - } - - QVariantMap values; - QSet nullColumns; - values.insert("SET_NAME", setName); - - if (!dbClient_.insertRecord(config_.tableSets, setColumns, values, nullColumns, error)) { - return false; - } - - QVariant idValue; - if (dbClient_.fetchSingleValue("SELECT LAST_INSERT_ID()", {}, &idValue, error)) { - bool ok = false; - int id = idValue.toInt(&ok); - if (ok) { - if (setIdOut) *setIdOut = id; - return true; - } - } - - if (!dbClient_.fetchSingleValue( - QString("SELECT `SET_ID` FROM `%1` WHERE `SET_NAME`=? ORDER BY `SET_ID` DESC LIMIT 1") - .arg(config_.tableSets), - {setName}, &idValue, error)) { - return false; - } - bool ok = false; - int id = idValue.toInt(&ok); - if (!ok) { - if (error) *error = "Unable to determine new SET_ID"; - return false; - } - if (setIdOut) *setIdOut = id; - return true; - } - - void openDatabase() { - dbClient_.close(); - QString error; - if (!dbClient_.connect(config_, &error)) { - connStatus_->setText("Connection failed"); - showError(this, "DB Connection", error.isEmpty() ? "Unable to connect" : error); - return; - } - - connStatus_->setText(QString("Connected: %1@%2:%3/%4") - .arg(config_.user) - .arg(config_.host) - .arg(config_.port) - .arg(config_.schema)); - - setsPanel_->setDatabase(&dbClient_, config_.tableSets); - setTargetsPanel_->setDatabase(&dbClient_, config_.tableTargets); - - settingsForSeq(); - } - - void settingsForSeq() { - seqConfigPath_ = configPath_; - ngpsRoot_ = inferNgpsRootFromConfig(seqConfigPath_); - initializeOtmStart(); - } - - QString resolveOtmPython() const { - QSettings settings(kSettingsOrg, kSettingsApp); - QString cmd = settings.value("otmPython").toString().trimmed(); - if (!cmd.isEmpty()) { - return cmd; - } - - const QString envCmd = qEnvironmentVariable("NGPS_PYTHON"); - if (!envCmd.isEmpty()) return envCmd; - - const QString venv = qEnvironmentVariable("VIRTUAL_ENV"); - if (!venv.isEmpty()) { - const QString candidate = QDir(venv).filePath("bin/python"); - if (QFileInfo::exists(candidate)) return candidate; - } - - const QString homeCandidate = QDir::home().filePath("venvs/ngps/bin/python"); - if (QFileInfo::exists(homeCandidate)) return homeCandidate; - - if (!ngpsRoot_.isEmpty()) { - const QString localVenv = QDir(ngpsRoot_).filePath("venv/bin/python"); - if (QFileInfo::exists(localVenv)) return localVenv; - } - - return "python3"; - } - - OtmSettings loadOtmSettings() const { - QSettings settings(kSettingsOrg, kSettingsApp); - OtmSettings s; - s.seeingFwhm = settings.value("otmSeeingFwhm", 1.1).toDouble(); - s.seeingPivot = settings.value("otmSeeingPivot", 500.0).toDouble(); - s.airmassMax = settings.value("otmAirmassMax", 4.0).toDouble(); - s.useSkySim = settings.value("otmUseSkySim", true).toBool(); - s.pythonCmd = settings.value("otmPython").toString(); - if (s.pythonCmd.trimmed().isEmpty()) { - const QString defaultPython = QDir::home().filePath("venvs/ngps/bin/python"); - if (QFileInfo::exists(defaultPython)) { - s.pythonCmd = defaultPython; - } - } - return s; - } - - void saveOtmSettings(const OtmSettings &settings) { - QSettings cfg(kSettingsOrg, kSettingsApp); - cfg.setValue("otmSeeingFwhm", settings.seeingFwhm); - cfg.setValue("otmSeeingPivot", settings.seeingPivot); - cfg.setValue("otmAirmassMax", settings.airmassMax); - cfg.setValue("otmUseSkySim", settings.useSkySim); - if (settings.pythonCmd.trimmed().isEmpty()) { - cfg.remove("otmPython"); - } else { - cfg.setValue("otmPython", settings.pythonCmd.trimmed()); - } - } - - QString loadOtmStart() const { - QSettings settings(kSettingsOrg, kSettingsApp); - return settings.value("otmStart").toString().trimmed(); - } - - void saveOtmStart() { - if (!otmStartEdit_) return; - if (otmUseNow_ && otmUseNow_->isChecked()) { - return; - } - QString text = otmStartEdit_->text().trimmed(); - if (text.isEmpty()) { - text = estimateTwilightUtc(); - if (text.isEmpty()) text = currentUtcString(); - otmStartEdit_->setText(text); - } - text = normalizeOtmStartText(text, true); - lastOtmStartManual_ = text; - QSettings settings(kSettingsOrg, kSettingsApp); - settings.setValue("otmStart", text); - } - - QString currentUtcString() const { - return QDateTime::currentDateTimeUtc().toString("yyyy-MM-ddTHH:mm:ss.zzz"); - } - - QString normalizeOtmStartText(const QString &text, bool quiet, bool *changed = nullptr) { - QString normalized = text.trimmed(); - if (normalized.contains(' ') && !normalized.contains('T')) { - normalized.replace(' ', 'T'); - } - if (!parseUtcIso(normalized).isValid()) { - QString fallback = estimateTwilightUtc(); - if (fallback.isEmpty()) fallback = currentUtcString(); - if (changed) *changed = true; - if (otmStartEdit_) { - otmStartEdit_->setText(fallback); - } - if (!quiet) { - statusBar()->showMessage( - QString("Invalid OTM start time; using %1").arg(fallback), 8000); - } - return fallback; - } - if (changed) *changed = (normalized != text.trimmed()); - return normalized; - } - - QString estimateTwilightUtc() const { - const QString pythonCmd = resolveOtmPython(); - if (pythonCmd.isEmpty()) return QString(); - - QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); - if (env.contains("PYTHONHOME")) env.remove("PYTHONHOME"); - if (env.contains("PYTHONPATH")) env.remove("PYTHONPATH"); - const QString addPath = QDir(ngpsRoot_).filePath("Python"); - env.insert("PYTHONPATH", addPath); - env.insert("PYTHONNOUSERSITE", "1"); - - QProcess proc; - proc.setProcessEnvironment(env); - if (!ngpsRoot_.isEmpty()) proc.setWorkingDirectory(ngpsRoot_); - - const QString code = R"PY( -from astropy.time import Time -from astropy.coordinates import EarthLocation, AltAz, get_sun -import astropy.units as u -import numpy as np -import sys - -loc = EarthLocation.of_site('Palomar') -t0 = Time.now() -target = -12.0 -times = t0 + np.linspace(0, 1, 289) * u.day # 5-min steps -alts = get_sun(times).transform_to(AltAz(obstime=times, location=loc)).alt.deg - -for i in range(len(alts) - 1): - if alts[i] > target and alts[i+1] <= target: - frac = (target - alts[i]) / (alts[i+1] - alts[i]) if alts[i+1] != alts[i] else 0.0 - t = times[i] + frac * (times[i+1] - times[i]) - print(t.utc.iso) - sys.exit(0) - -# fallback: any crossing -for i in range(len(alts) - 1): - if (alts[i] - target) * (alts[i+1] - target) <= 0: - frac = (target - alts[i]) / (alts[i+1] - alts[i]) if alts[i+1] != alts[i] else 0.0 - t = times[i] + frac * (times[i+1] - times[i]) - print(t.utc.iso) - sys.exit(0) - -print(t0.utc.iso) -)PY"; - - proc.start(pythonCmd, {"-c", code}); - if (!proc.waitForFinished(6000)) { - return QString(); - } - if (proc.exitStatus() != QProcess::NormalExit || proc.exitCode() != 0) { - return QString(); - } - const QString out = QString::fromUtf8(proc.readAllStandardOutput()).trimmed(); - return out; - } - - void initializeOtmStart() { - if (!otmStartEdit_) return; - const QString saved = loadOtmStart(); - if (!saved.isEmpty()) { - otmStartEdit_->setText(saved); - lastOtmStartManual_ = saved; - return; - } - - const QString twilight = estimateTwilightUtc(); - const QString useVal = twilight.isEmpty() ? currentUtcString() : twilight; - otmStartEdit_->setText(useVal); - lastOtmStartManual_ = useVal; - saveOtmStart(); - } - - void handleOtmUseNowToggle(bool checked) { - if (!otmStartEdit_) return; - if (checked) { - lastOtmStartManual_ = otmStartEdit_->text().trimmed(); - otmStartEdit_->setText(currentUtcString()); - otmStartEdit_->setEnabled(false); - } else { - otmStartEdit_->setEnabled(true); - if (!lastOtmStartManual_.isEmpty()) { - otmStartEdit_->setText(lastOtmStartManual_); - } else { - initializeOtmStart(); - } - } - saveOtmStart(); - scheduleAutoOtmRun(); - } - - void runSeqCommandAndCapture(const QStringList &args, - const std::function &onFinished) { - SeqProcessConfig cfg = seqProcessConfig(); - QProcess *proc = new QProcess(this); - proc->setProcessEnvironment(cfg.env); - if (!cfg.workDir.isEmpty()) { - proc->setWorkingDirectory(cfg.workDir); - } - - auto output = std::make_shared(); - statusBar()->showMessage(QString("Running: %1 %2").arg(cfg.cmd, args.join(' ')), 5000); - connect(proc, &QProcess::readyReadStandardOutput, this, [proc, output]() { - *output += QString::fromUtf8(proc->readAllStandardOutput()); - }); - connect(proc, &QProcess::readyReadStandardError, this, [proc, output]() { - *output += QString::fromUtf8(proc->readAllStandardError()); - }); - connect(proc, QOverload::of(&QProcess::finished), this, - [this, proc, output, onFinished](int code, QProcess::ExitStatus status) { - const QString msg = QString("Seq exit %1 (%2)") - .arg(code) - .arg(status == QProcess::NormalExit ? "normal" : "crash"); - statusBar()->showMessage(msg, 5000); - if (code != 0 && !output->isEmpty()) { - showWarning(this, "Sequencer", *output); - } - if (onFinished) { - onFinished(code, *output); - } - proc->deleteLater(); - }); - - proc->start(cfg.cmd, args); - } - - void seqStartWithStartupCheck() { - runSeqCommandAndCapture({"state"}, [this](int code, const QString &output) { - if (code == 0) { - const bool ready = outputHasState(output, "READY"); - const bool running = outputHasState(output, "RUNNING"); - const bool starting = outputHasState(output, "STARTING"); - if (running || starting) { - statusBar()->showMessage("Sequencer already running/starting.", 5000); - return; - } - if (!ready && !running && !starting) { - runSeqCommandAndCapture({"startup"}, - [this](int, const QString &) { runSeqCommand({"start"}); }); - return; - } - runSeqCommand({"start"}); - return; - } - runSeqCommand({"start"}); - }); - } - - void runSeqCommand(const QStringList &args) { - SeqProcessConfig cfg = seqProcessConfig(); - QProcess *proc = new QProcess(this); - proc->setProcessEnvironment(cfg.env); - if (!cfg.workDir.isEmpty()) { - proc->setWorkingDirectory(cfg.workDir); - } - - auto output = std::make_shared(); - statusBar()->showMessage(QString("Running: %1 %2").arg(cfg.cmd, args.join(' ')), 5000); - connect(proc, &QProcess::readyReadStandardOutput, this, [proc, output]() { - *output += QString::fromUtf8(proc->readAllStandardOutput()); - }); - connect(proc, &QProcess::readyReadStandardError, this, [proc, output]() { - *output += QString::fromUtf8(proc->readAllStandardError()); - }); - connect(proc, QOverload::of(&QProcess::finished), this, - [this, proc, output](int code, QProcess::ExitStatus status) { - const QString msg = QString("Seq exit %1 (%2)") - .arg(code) - .arg(status == QProcess::NormalExit ? "normal" : "crash"); - statusBar()->showMessage(msg, 5000); - if (code != 0 && !output->isEmpty()) { - showWarning(this, "Sequencer", *output); - } - proc->deleteLater(); - }); - - proc->start(cfg.cmd, args); - } - - QTabWidget *tabs_ = nullptr; - TablePanel *setsPanel_ = nullptr; - TablePanel *setTargetsPanel_ = nullptr; - TimelinePanel *timelinePanel_ = nullptr; - - QLabel *connStatus_ = nullptr; - - QPushButton *seqStart_ = nullptr; - QPushButton *seqAbort_ = nullptr; - QPushButton *runOtm_ = nullptr; - QPushButton *showOtmLog_ = nullptr; - QLineEdit *otmStartEdit_ = nullptr; - QCheckBox *otmUseNow_ = nullptr; - QString lastOtmStartManual_; - QString lastOtmLog_; - QTimer *otmAutoTimer_ = nullptr; - bool otmRunning_ = false; - bool otmAutoPending_ = false; - bool closing_ = false; - - DbConfig config_; - DbClient dbClient_; - - QString configPath_; - QString seqConfigPath_; - QString ngpsRoot_; -}; - -int main(int argc, char *argv[]) { - QApplication app(argc, argv); - MainWindow window; - window.resize(1200, 800); - window.show(); - return app.exec(); -} - -#include "main.moc" From ed1d829f9e95712ef76c7514f4e1617b5f5256f3 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Wed, 11 Feb 2026 23:20:03 -0800 Subject: [PATCH 74/74] Simplify seq-progress exposure percent handling --- utils/seq_progress_gui.cpp | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/utils/seq_progress_gui.cpp b/utils/seq_progress_gui.cpp index 80fc579f..5172961b 100644 --- a/utils/seq_progress_gui.cpp +++ b/utils/seq_progress_gui.cpp @@ -950,13 +950,8 @@ class SeqProgressGui { } if (jmessage.contains("exptime_percent") && jmessage["exptime_percent"].is_number()) { int percent = jmessage["exptime_percent"].get(); - double new_progress = std::min(1.0, percent / 100.0); - // Smooth the percentage (exponential moving average) - if (state_.exposure_progress > 0.0) { - state_.exposure_progress = 0.7 * state_.exposure_progress + 0.3 * new_progress; - } else { - state_.exposure_progress = new_progress; - } + percent = std::max(0, std::min(100, percent)); + state_.exposure_progress = percent / 100.0; set_phase(PHASE_EXPOSE); } } else if (topic == "acamd") {