File indexing completed on 2024-04-28 15:09:04

0001 /*
0002     SPDX-FileCopyrightText: 2021 Hy Murveit <hy@murveit.com>
0003 
0004     SPDX-License-Identifier: GPL-2.0-or-later
0005 */
0006 
0007 #include "polaralign.h"
0008 #include "poleaxis.h"
0009 #include "rotations.h"
0010 
0011 #include <cmath>
0012 
0013 #include "fitsviewer/fitsdata.h"
0014 #include "kstarsdata.h"
0015 #include "skypoint.h"
0016 #include <ekos_align_debug.h>
0017 
0018 /******************************************************************
0019 PolarAlign is a class that supports polar alignment by determining the
0020 mount's axis of rotation when given 3 solved images taken with RA mount
0021 rotations between the images.
0022 
0023 addPoint(image) is called by the polar alignment UI after it takes and
0024 solves each of its three images. The solutions are store in SkyPoints (see below)
0025 and are processed so that the sky positions correspond to "what's in the sky
0026 now" and "at this geographic localtion".
0027 
0028 Addpoint() samples the location of a particular pixel in its image.
0029 When the 3 points are sampled, they should not be taken
0030 from the center of the image, as HA rotations may not move that point
0031 if the telescope and mount are well aligned. Thus, the points are sampled
0032 from the edge of the image.
0033 
0034 After all 3 images are sampled, findAxis() is called, which solves for the mount's
0035 axis of rotation. It then transforms poleAxis' result into azimuth and altitude
0036 offsets from the pole.
0037 
0038 After the mount's current RA axis is determined, the user then attempts to correct/improve
0039 it to match the Earth's real polar axes. Ekos has two techniques to do that. In both
0040 Ekos takes a series of "refresh images".  The user looks at the images and their
0041 associated analyses and adjusts the mount's altitude and azimuth knobs.
0042 
0043 In the first scheme, the user identifies a refrence star on the image. Ekos draws a triangle
0044 over the image, and user attempts to "move the star" along two sides of that triangle.
0045 
0046 In the 2nd scheme, the system plate-solves the refresh images, telling the user which direction
0047 and how much to adjust the knobs.
0048 
0049 findCorrectedPixel() supports the "move the star" refresh scheme.
0050 It is given an x,y position on an image and the offsets
0051 generated by findAxis(). It computes a "corrected position" for that input
0052 x,y point such that if a user adjusted the GEM mount's altitude and azimuth
0053 knobs to move a star centered in the image's original x,y position to the corrected
0054 position in the image, the mount's axis of rotation should then coincide with the pole.
0055 
0056 processRefreshCoords() supports the plate-solving refresh scheme.
0057 It is given the center coordinates of a refresh image. It remembers the originally
0058 calculated mount axis, and the position of the 3rd measurement image. It computes how
0059 much the user has already adjusted the azimuth and altitude knobs from the difference
0060 in pointing between the new refresh image's center coordinates and that of the 3rd measurement
0061 image. It infers what the mounts new RA axis must be (based on that adjustment) and returns
0062 the new polar alignment error.
0063 ******************************************************************/
0064 
0065 using Rotations::V3;
0066 
0067 PolarAlign::PolarAlign(const GeoLocation *geo)
0068 {
0069     if (geo == nullptr && KStarsData::Instance() != nullptr)
0070         geoLocation = KStarsData::Instance()->geo();
0071     else
0072         geoLocation = geo;
0073 }
0074 
0075 bool PolarAlign::northernHemisphere() const
0076 {
0077     if ((geoLocation == nullptr) || (geoLocation->lat() == nullptr))
0078         return true;
0079     return geoLocation->lat()->Degrees() > 0;
0080 }
0081 
0082 void PolarAlign::reset()
0083 {
0084     points.clear();
0085     times.clear();
0086 }
0087 
0088 // Gets the pixel's j2000 RA&DEC coordinates, converts to JNow,  adjust to
0089 // the local time, and sets up the azimuth and altitude coordinates.
0090 bool PolarAlign::prepareAzAlt(const QSharedPointer<FITSData> &image, const QPointF &pixel, SkyPoint *point) const
0091 {
0092     // WCS must be set up for this image.
0093     SkyPoint coords;
0094     if (image && image->pixelToWCS(pixel, coords))
0095     {
0096         coords.apparentCoord(static_cast<long double>(J2000), image->getDateTime().djd());
0097         *point = SkyPoint::timeTransformed(&coords, image->getDateTime(), geoLocation, 0);
0098         return true;
0099     }
0100     return false;
0101 }
0102 
0103 bool PolarAlign::addPoint(const QSharedPointer<FITSData> &image)
0104 {
0105     SkyPoint coords;
0106     auto time = image->getDateTime();
0107     // Use the HA and DEC from the center of the image.
0108     if (!prepareAzAlt(image, QPointF(image->width() / 2, image->height() / 2), &coords))
0109         return false;
0110 
0111     QString debugString = QString("PAA: addPoint ra0 %1 dec0 %2 ra %3 dec %4 az %5 alt %6")
0112                           .arg(coords.ra0().Degrees()).arg(coords.dec0().Degrees())
0113                           .arg(coords.ra().Degrees()).arg(coords.dec().Degrees())
0114                           .arg(coords.az().Degrees()).arg(coords.alt().Degrees());
0115     qCInfo(KSTARS_EKOS_ALIGN) << debugString;
0116     if (points.size() > 2)
0117         return false;
0118     points.push_back(coords);
0119     times.push_back(time);
0120 
0121     return true;
0122 }
0123 
0124 namespace
0125 {
0126 
0127 // Returns the distance, in absolute-value degrees, of taking point "from",
0128 // rotating it around the Y axis by yAngle, then rotating around the Z axis
0129 // by zAngle and comparing that with "goal".
0130 double getResidual(const V3 &from, double yAngle, double zAngle, const V3 &goal)
0131 {
0132     V3 point1 = Rotations::rotateAroundY(from, yAngle);
0133     V3 point2 = Rotations::rotateAroundZ(point1, zAngle);
0134     return fabs(getAngle(point2, goal));
0135 }
0136 
0137 // Finds the best rotations to change from pointing to 'from' to pointing to 'goal'.
0138 // It tries 'all' possible pairs of x and y rotations (sampled by increment).
0139 // Note that you can't simply find the best Z rotation, and the go from there to find the best Y.
0140 // The space is non-linear, and that would often lead to poor solutions.
0141 double getBestRotation(const V3 &from, const V3 &goal,
0142                        double zStart, double yStart,
0143                        double *bestAngleZ, double *bestAngleY,
0144                        double range, double increment)
0145 {
0146 
0147     *bestAngleZ = 0;
0148     *bestAngleY = 0;
0149     double minDist = 1e8;
0150     range = fabs(range);
0151     for (double thetaY = yStart - range; thetaY <= yStart + range; thetaY += increment)
0152     {
0153         for (double thetaZ = zStart - range; thetaZ <= zStart + range; thetaZ += increment)
0154         {
0155             double dist = getResidual(from, thetaY, thetaZ, goal);
0156             if (dist < minDist)
0157             {
0158                 minDist = dist;
0159                 *bestAngleY = thetaY;
0160                 *bestAngleZ = thetaZ;
0161             }
0162         }
0163     }
0164     return minDist;
0165 }
0166 
0167 // Computes the rotations in Y (altitude) and Z (azimuth) that brings 'from' closest to 'goal'.
0168 // Returns the residual (error angle between where these rotations lead and "goal".
0169 double getRotationAngles(const V3 &from, const V3 &goal, double *zAngle, double *yAngle)
0170 {
0171     // All in degrees.
0172     constexpr double pass1Resolution = 1.0 / 60.0;
0173     constexpr double pass2Resolution = 5 / 3600.0;
0174     constexpr double pass2Range = 4.0 / 60.0;
0175 
0176     // Compute the rotation using a great circle. This somewhat constrains our search below.
0177     const double rotationAngle = getAngle(from, goal); // degrees
0178     const double pass1Range = std::max(1.0, std::min(5.0, fabs(rotationAngle)));
0179 
0180     // Grid search across all y,z angle possibilities, sampling by 2 arc-minutes.
0181     const double pass1Residual = getBestRotation(from, goal, 0, 0, zAngle, yAngle, pass1Range, pass1Resolution);
0182     Q_UNUSED(pass1Residual);
0183 
0184     // Refine the search around the best solution so far
0185     return getBestRotation(from, goal, *zAngle, *yAngle, zAngle, yAngle, pass2Range, pass2Resolution);
0186 }
0187 
0188 }  // namespace
0189 
0190 // Compute the polar-alignment azimuth and altitude error by comparing the new image's coordinates
0191 // with the coordinates from the 3rd measurement image. Use the difference to infer a rotation angle,
0192 // and rotate the originally computed polar-alignment axis by that angle to find the new axis
0193 // around which RA now rotates.
0194 bool PolarAlign::processRefreshCoords(const SkyPoint &coords, const KStarsDateTime &time,
0195                                       double *azError, double *altError,
0196                                       double *azAdjustment, double *altAdjustment) const
0197 {
0198     // Get the az and alt from this new measurement (coords), and from that derive its x,y,z coordinates.
0199     auto c = coords;  // apparentCoord modifies its input. Use the temp variable c to keep coords const.
0200     c.apparentCoord(static_cast<long double>(J2000), time.djd());
0201     SkyPoint point = SkyPoint::timeTransformed(&c, time, geoLocation, 0);
0202     const double az = point.az().Degrees(), alt = point.alt().Degrees();
0203     const V3 newPoint = Rotations::azAlt2xyz(QPointF(az, alt));
0204 
0205     // Get the x,y,z coordinates of the original position (from the 3rd polar-align image).
0206     // We can't simply use the az/alt already computed for point3 because the mount is tracking,
0207     // and thus, even if the user made no adjustments, but simply took an image a little while
0208     // later at the same RA/DEC coordinates, the Az/Alt would have changed and we'd believe there
0209     // was a user rotation due to changes in the Az/Alt knobs. Instead we must convert point3's az/alt
0210     // values to what they would be if that image had been taken now, still pointing at point3's ra/dec.
0211     // The key is to rotate the original point around the original RA axis by the rotation given by the time
0212     // difference multiplied by the sidereal rate.
0213 
0214     // Figure out what the az/alt would be if the user hadn't modified the knobs.
0215     // That is, just rotate the 3rd measurement point around the mount's original RA axis.
0216     // Time since third point in seconds
0217     const double p3secs = times[2].secsTo(time);
0218     // Angle corresponding to that interval assuming the sidereal rate.
0219     const double p3Angle = (-15.041067 * p3secs) / 3600.0;  // degrees
0220 
0221     // Get the xyz coordinates of the original 3rd point.
0222     const V3 p3OrigPoint = Rotations::azAlt2xyz(QPointF(points[2].az().Degrees(), points[2].alt().Degrees()));
0223     // Get the unit vector corresponding the original RA axis
0224     const V3 origAxisPt = Rotations::azAlt2xyz(QPointF(azimuthCenter, altitudeCenter));
0225     // Rotate the original 3rd point around that axis, simulating the mount's tracking movements.
0226     const V3 point3 = Rotations::rotateAroundAxis(p3OrigPoint, origAxisPt, p3Angle);
0227 
0228     // Find the adjustment the user must have made by examining the change from point3 to newPoint
0229     // (i.e. the rotation caused by the user adjusting the azimuth and altitude knobs).
0230     // We assume that this was a rotation around a level mount's y axis and z axis.
0231     double zAdjustment, yAdjustment;
0232     double residual = getRotationAngles(point3, newPoint, &zAdjustment, &yAdjustment);
0233     if (residual > 0.5)
0234     {
0235         qCInfo(KSTARS_EKOS_ALIGN) << QString("PAA refresh: failed to estimate rotation angle (residual %1'").arg(residual * 60);
0236         return false;
0237     }
0238     qCInfo(KSTARS_EKOS_ALIGN) << QString("PAA refresh: Estimated current adjustment: Az %1' Alt %2' residual %3a-s")
0239                               .arg(zAdjustment * 60, 0, 'f', 1).arg(yAdjustment * 60, 0, 'f', 1).arg(residual * 3600, 0, 'f', 0);
0240 
0241     // Return the estimated adjustments (used by testing).
0242     if (altAdjustment != nullptr) *altAdjustment = yAdjustment;
0243     if (azAdjustment != nullptr) *azAdjustment = zAdjustment;
0244 
0245     // Rotate the original RA axis position by the above adjustments.
0246     const V3 origAxisPoint = Rotations::azAlt2xyz(QPointF(azimuthCenter, altitudeCenter));
0247     const V3 tempPoint = Rotations::rotateAroundY(origAxisPoint, yAdjustment);
0248     const V3 newAxisPoint = Rotations::rotateAroundZ(tempPoint, zAdjustment);
0249 
0250     // Convert the rotated axis point back to an az/alt coordinate, representing the new RA axis.
0251     const QPointF newAxisAzAlt = Rotations::xyz2azAlt(newAxisPoint);
0252     const double newAxisAz = newAxisAzAlt.x();
0253     const double newAxisAlt = newAxisAzAlt.y();
0254 
0255     // Compute the polar alignment error for the new RA axis.
0256     const double latitudeDegrees = geoLocation->lat()->Degrees();
0257     *altError = northernHemisphere() ? newAxisAlt - latitudeDegrees : newAxisAlt + latitudeDegrees;
0258     *azError = northernHemisphere() ? newAxisAz : newAxisAz + 180.0;
0259     while (*azError > 180.0)
0260         *azError -= 360;
0261 
0262     QString infoString =
0263         QString("PAA refresh: ra0 %1 dec0 %2 Az/Alt: %3 %4 AXIS: %5 %6 --> %7 %8 ERR: %9' alt %10'")
0264         .arg(coords.ra0().Degrees(), 0, 'f', 3).arg(coords.dec0().Degrees(), 0, 'f', 3)
0265         .arg(az, 0, 'f', 3).arg(alt, 0, 'f', 3)
0266         .arg(azimuthCenter, 0, 'f', 3).arg(altitudeCenter, 0, 'f', 3)
0267         .arg(newAxisAz, 0, 'f', 3).arg(newAxisAlt, 0, 'f', 3)
0268         .arg(*azError * 60, 0, 'f', 1).arg(*altError * 60, 0, 'f', 1);
0269     qCInfo(KSTARS_EKOS_ALIGN) << infoString;
0270 
0271     return true;
0272 }
0273 
0274 // Given the telescope's current RA axis, and the its current pointing position,
0275 // compute the coordinates where it should point such that its RA axis will be at the pole.
0276 bool PolarAlign::refreshSolution(SkyPoint *solution, SkyPoint *altOnlySolution) const
0277 {
0278     if (points.size() != 3)
0279         return false;
0280 
0281     double azError, altError;
0282     calculateAzAltError(&azError, &altError);
0283 
0284     // The Y rotation to correct polar alignment is -altitude error, and the Z correction is -azimuth error.
0285     // Rotate the 3rd-image center coordinate by the above angles.
0286     // This is the position the telescope needs to point to (if it is taken there
0287     // by adjusting alt and az knobs) such that the new RA rotation axis is aligned with the pole.
0288     const V3 point3 = Rotations::azAlt2xyz(QPointF(points[2].az().Degrees(), points[2].alt().Degrees()));
0289     const V3 altSolutionPoint = Rotations::rotateAroundY(point3, altError);
0290     const V3 solutionPoint = Rotations::rotateAroundZ(altSolutionPoint, azError);
0291 
0292     // Convert the solution xyz points back to az/alt and ra/dec.
0293     const QPointF solutionAzAlt = Rotations::xyz2azAlt(solutionPoint);
0294     solution->setAz(solutionAzAlt.x());
0295     solution->setAlt(solutionAzAlt.y());
0296     auto lst = geoLocation->GSTtoLST(times[2].gst());
0297     solution->HorizontalToEquatorial(&lst, geoLocation->lat());
0298 
0299     // Not sure if this is needed
0300     solution->setRA0(solution->ra());
0301     solution->setDec0(solution->dec());
0302 
0303     // Move the solution back to J2000
0304     KSNumbers num(times[2].djd());
0305     *solution = solution->deprecess(&num);
0306     solution->setRA0(solution->ra());
0307     solution->setDec0(solution->dec());
0308 
0309     // Ditto for alt-only solution
0310     const QPointF altOnlySolutionAzAlt = Rotations::xyz2azAlt(altSolutionPoint);
0311     altOnlySolution->setAz(altOnlySolutionAzAlt.x());
0312     altOnlySolution->setAlt(altOnlySolutionAzAlt.y());
0313     auto altOnlyLst = geoLocation->GSTtoLST(times[2].gst());
0314     altOnlySolution->HorizontalToEquatorial(&altOnlyLst, geoLocation->lat());
0315     altOnlySolution->setRA0(altOnlySolution->ra());
0316     altOnlySolution->setDec0(altOnlySolution->dec());
0317     KSNumbers altOnlyNum(times[2].djd());
0318     *altOnlySolution = altOnlySolution->deprecess(&altOnlyNum);
0319     altOnlySolution->setRA0(altOnlySolution->ra());
0320     altOnlySolution->setDec0(altOnlySolution->dec());
0321 
0322     return true;
0323 }
0324 
0325 bool PolarAlign::findAxis()
0326 {
0327     if (points.size() != 3)
0328         return false;
0329 
0330     // We have 3 points, get their xyz positions.
0331     V3 p1(Rotations::azAlt2xyz(QPointF(points[0].az().Degrees(), points[0].alt().Degrees())));
0332     V3 p2(Rotations::azAlt2xyz(QPointF(points[1].az().Degrees(), points[1].alt().Degrees())));
0333     V3 p3(Rotations::azAlt2xyz(QPointF(points[2].az().Degrees(), points[2].alt().Degrees())));
0334     V3 axis = Rotations::getAxis(p1, p2, p3);
0335 
0336     if (axis.length() < 0.9)
0337     {
0338         // It failed to normalize the vector, something's wrong.
0339         qCInfo(KSTARS_EKOS_ALIGN) << "Normal vector too short. findAxis failed.";
0340         return false;
0341     }
0342 
0343     // Need to make sure we're pointing to the right pole.
0344     if ((northernHemisphere() && (axis.x() < 0)) || (!northernHemisphere() && axis.x() > 0))
0345     {
0346         axis = V3(-axis.x(), -axis.y(), -axis.z());
0347     }
0348 
0349     QPointF azAlt = Rotations::xyz2azAlt(axis);
0350     azimuthCenter = azAlt.x();
0351     altitudeCenter = azAlt.y();
0352 
0353     return true;
0354 }
0355 
0356 void PolarAlign::getAxis(double *azAxis, double *altAxis) const
0357 {
0358     *azAxis = azimuthCenter;
0359     *altAxis = altitudeCenter;
0360 }
0361 
0362 // Find the pixel in image corresponding to the desired azimuth & altitude.
0363 bool PolarAlign::findAzAlt(const QSharedPointer<FITSData> &image, double azimuth, double altitude, QPointF *pixel) const
0364 {
0365     SkyPoint spt;
0366     spt.setAz(azimuth);
0367     spt.setAlt(altitude);
0368     dms LST = geoLocation->GSTtoLST(image->getDateTime().gst());
0369     spt.HorizontalToEquatorial(&LST, geoLocation->lat());
0370     SkyPoint j2000Coord = spt.catalogueCoord(image->getDateTime().djd());
0371     QPointF imagePoint;
0372     if (!image->wcsToPixel(j2000Coord, *pixel, imagePoint))
0373     {
0374         QString debugString =
0375             QString("PolarAlign: Couldn't get pixel from WCS for az %1 alt %2 with j2000 RA %3 DEC %4")
0376             .arg(QString::number(azimuth), QString::number(altitude), j2000Coord.ra0().toHMSString(), j2000Coord.dec0().toDMSString());
0377         qCInfo(KSTARS_EKOS_ALIGN) << debugString;
0378         return false;
0379     }
0380     return true;
0381 }
0382 
0383 // Calculate the mount's azimuth and altitude error given the known geographic location
0384 // and the azimuth center and altitude center computed in findAxis().
0385 void PolarAlign::calculateAzAltError(double *azError, double *altError) const
0386 {
0387     const double latitudeDegrees = geoLocation->lat()->Degrees();
0388     *altError = northernHemisphere() ?
0389                 altitudeCenter - latitudeDegrees : altitudeCenter + latitudeDegrees;
0390     *azError = northernHemisphere() ? azimuthCenter : azimuthCenter + 180.0;
0391     while (*azError > 180.0)
0392         *azError -= 360;
0393 }
0394 
0395 void PolarAlign::setMaxPixelSearchRange(double degrees)
0396 {
0397     // Suggestion for how far pixelError() below searches.
0398     // Don't allow the search to be modified too much.
0399     const double d = fabs(degrees);
0400     if (d < 2)
0401         maxPixelSearchRange = 2.0;
0402     else if (d > 10)
0403         maxPixelSearchRange = 10.0;
0404     else
0405         maxPixelSearchRange = d;
0406 }
0407 
0408 // Given the currently estimated RA axis polar alignment error, and given a start pixel,
0409 // find the polar-alignment error if the user moves a star (from his point of view)
0410 // from that pixel to pixel2.
0411 //
0412 // FindCorrectedPixel() determines where the user should move the star to fully correct
0413 // the alignment error. However, while the user is doing that, he/she may be at an intermediate
0414 // point (pixel2) and we want to feed back to the user what the "current" polar-alignment error is.
0415 // This searches using findCorrectedPixel() to
0416 // find the RA axis error which would be fixed by the user moving pixel to pixel2. The input
0417 // thus should be pixel = "current star position", and pixel2 = "solution star position"
0418 // from the original call to findCorrectedPixel. This calls findCorrectedPixel several hundred times
0419 // but is not too costly (about .1s on a RPi4).  One could write a method that more directly estimates
0420 // the error given the current position, but it might not be applicable to our use-case as
0421 // we are constrained to move along paths detemined by a user adjusting an altitude knob and then
0422 // an azimuth adjustment. These corrections are likely not the most direct path to solve the axis error.
0423 bool PolarAlign::pixelError(const QSharedPointer<FITSData> &image, const QPointF &pixel, const QPointF &pixel2,
0424                             double *azError, double *altError)
0425 {
0426     double azOffset, altOffset;
0427     calculateAzAltError(&azOffset, &altOffset);
0428 
0429     QPointF pix;
0430     double azE = 0, altE = 0;
0431 
0432     pixelError(image, pixel, pixel2,
0433                -maxPixelSearchRange, maxPixelSearchRange, 0.2,
0434                -maxPixelSearchRange, maxPixelSearchRange, 0.2, &azE, &altE, &pix);
0435     pixelError(image, pixel, pixel2, azE - .2, azE + .2, 0.02,
0436                altE - .2, altE + .2, 0.02, &azE, &altE, &pix);
0437     pixelError(image, pixel, pixel2, azE - .02, azE + .02, 0.002,
0438                altE - .02, altE + .02, 0.002, &azE, &altE, &pix);
0439 
0440     const double pixDist = hypot(pix.x() - pixel2.x(), pix.y() - pixel2.y());
0441     if (pixDist > 10)
0442         return false;
0443 
0444     *azError = azE;
0445     *altError = altE;
0446     return true;
0447 }
0448 
0449 void PolarAlign::pixelError(const QSharedPointer<FITSData> &image, const QPointF &pixel, const QPointF &pixel2,
0450                             double minAz, double maxAz, double azInc,
0451                             double minAlt, double maxAlt, double altInc,
0452                             double *azError, double *altError, QPointF *actualPixel)
0453 {
0454     double minDistSq = 1e9;
0455     for (double eAz = minAz; eAz < maxAz; eAz += azInc)
0456     {
0457         for (double eAlt = minAlt; eAlt < maxAlt; eAlt += altInc)
0458         {
0459             QPointF pix;
0460             if (findCorrectedPixel(image, pixel, &pix, eAz, eAlt))
0461             {
0462                 // compare the distance to the pixel
0463                 double distSq = ((pix.x() - pixel2.x()) * (pix.x() - pixel2.x()) +
0464                                  (pix.y() - pixel2.y()) * (pix.y() - pixel2.y()));
0465                 if (distSq < minDistSq)
0466                 {
0467                     minDistSq = distSq;
0468                     *actualPixel = pix;
0469                     *azError = eAz;
0470                     *altError = eAlt;
0471                 }
0472             }
0473         }
0474     }
0475 }
0476 
0477 // Given a pixel, find its RA/DEC, then its alt/az, and then solve for another pixel
0478 // where, if the star in pixel is moved to that star in the user's image (by adjusting alt and az controls)
0479 // the polar alignment error would be 0.
0480 bool PolarAlign::findCorrectedPixel(const QSharedPointer<FITSData> &image, const QPointF &pixel, QPointF *corrected,
0481                                     bool altOnly)
0482 {
0483     double azOffset, altOffset;
0484     calculateAzAltError(&azOffset, &altOffset);
0485     if (altOnly)
0486         azOffset = 0.0;
0487     return findCorrectedPixel(image, pixel, corrected, azOffset, altOffset);
0488 }
0489 
0490 // Given a pixel, find its RA/DEC, then its alt/az, and then solve for another pixel
0491 // where, if the star in pixel is moved to that star in the user's image (by adjusting alt and az controls)
0492 // the polar alignment error would be 0. We use the fact that we can only move by adjusting and altitude
0493 // knob, then an azimuth knob--i.e. we likely don't traverse a great circle.
0494 bool PolarAlign::findCorrectedPixel(const QSharedPointer<FITSData> &image, const QPointF &pixel, QPointF *corrected,
0495                                     double azOffset,
0496                                     double altOffset)
0497 {
0498     // 1. Find the az/alt for the x,y point on the image.
0499     SkyPoint p;
0500     if (!prepareAzAlt(image, pixel, &p))
0501         return false;
0502     double pixelAz = p.az().Degrees(), pixelAlt = p.alt().Degrees();
0503 
0504     // 2. Apply the az/alt offsets.
0505     // We know that the pole's az and alt offsets are effectively rotations
0506     // of a sphere. The offsets that apply to correct different points depend
0507     // on where (in the sphere) those points are. Points close to the pole can probably
0508     // just add the pole's offsets. This calculation is a bit more precise, and is
0509     // necessary if the points are not near the pole.
0510     double altRotation = northernHemisphere() ? altOffset : -altOffset;
0511     QPointF rotated = Rotations::rotateRaAxis(QPointF(pixelAz, pixelAlt), QPointF(azOffset, altRotation));
0512 
0513     // 3. Find a pixel with those alt/az values.
0514     if (!findAzAlt(image, rotated.x(), rotated.y(), corrected))
0515         return false;
0516 
0517     return true;
0518 }