{"action":"created","comment":{"url":"https://api.github.com/repos/lsst/cbp/pulls/comments/198887170","pull_request_review_id":132841969,"id":198887170,"node_id":"MDI0OlB1bGxSZXF1ZXN0UmV2aWV3Q29tbWVudDE5ODg4NzE3MA==","diff_hunk":"@@ -0,0 +1,556 @@\n+# This file is part of cbp.\n+#\n+# Developed for the LSST Data Management System.\n+# This product includes software developed by the LSST Project\n+# (https://www.lsst.org).\n+# See the COPYRIGHT file at the top-level directory of this distribution\n+# for details of code ownership.\n+#\n+# This program is free software: you can redistribute it and/or modify\n+# it under the terms of the GNU General Public License as published by\n+# the Free Software Foundation, either version 3 of the License, or\n+# (at your option) any later version.\n+#\n+# This program is distributed in the hope that it will be useful,\n+# but WITHOUT ANY WARRANTY; without even the implied warranty of\n+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n+# GNU General Public License for more details.\n+#\n+# You should have received a copy of the GNU General Public License\n+# along with this program.  If not, see <https://www.gnu.org/licenses/>.\n+\n+import itertools\n+import math\n+import unittest\n+\n+import numpy as np\n+\n+from lsst.sphgeom import Vector3d\n+from lsst.afw.cameraGeom import FIELD_ANGLE, FOCAL_PLANE\n+from lsst.geom import Box2D, Point2D, SpherePoint, arcseconds, degrees, radians\n+import lsst.cbp.coordUtils as coordUtils\n+from lsst.cbp.testUtils import SampleCoordinateConverter\n+import lsst.cbp.coordinateConverter\n+import lsst.utils.tests\n+\n+# set True to record and report numeric error in convertVectorFromPupilToBase\n+# and in the iterative component of setFocalFieldAngle\n+ReportRecordedErrors = True\n+\n+\n+class CoordConverterTestCase(lsst.utils.tests.TestCase):\n+    def setUp(self):\n+        if ReportRecordedErrors:\n+            lsst.cbp.coordUtils.startRecordingErrors()\n+            lsst.cbp.coordinateConverter.startRecordingErrors()\n+        self.scc = SampleCoordinateConverter()\n+        self.cco = self.scc.coordinateConverter\n+        # a list of all detector names plus None for the default detector\n+        self.detectorNames = itertools.chain([None], self.cco.cameraGeom.getNameIter())\n+        # set values for maximum error that are \"plenty good enough\"\n+        self.maxPupilPosErr = 1e-4  # mm\n+        self.maxDetectorPosErr = 1e-4  # pixels\n+        self.maxFocalPlanePosErr = self.scc.pixelSizeMm * self.maxDetectorPosErr  # mm\n+        self.maxFieldAngleErrRad = (0.001*arcseconds).asRadians()\n+\n+    def tearDown(self):\n+        if ReportRecordedErrors:\n+            utilsErrList = coordUtils.getRecordedErrors()\n+            coordUtils.stopRecordingErrors()\n+            if len(utilsErrList) > 0:\n+                print(\"\\nWarning: recorded {} numerical errors in computeAzAltFromBasePupil;\"\n+                      \" the worst 5 are:\".format(len(utilsErrList)))\n+                for err, vectorBase, vectorPupil in utilsErrList[-5:]:\n+                    print(\"error={:0.5f} arcsec, vectorBase={}, vectorPupil={}\".format(\n+                        err, vectorBase, vectorPupil))\n+\n+            errorList = lsst.cbp.coordinateConverter.getRecordedErrors()\n+            if len(errorList) > 0:\n+                errorArray = np.array([val[0] for val in errorList])\n+                print(\"\\nroot finder mean error={:0.3g}, sdev={:0.3g}, min={:0.3g}, max={:0.3g}\".format(\n+                      errorArray.mean(), errorArray.std(), errorArray.min(), errorArray.max()))\n+        del self.cco\n+        del self.scc\n+\n+    def testOffsetDetectorPos(self):\n+        pupilPos = (3000, 4000)\n+        detectorPos = (400, 550)\n+        for pupilOffset, detectorOffset, detector, beam in itertools.product(\n+            (None, (0, 0), (500, -300)),\n+            (None, (0, 0), (-50, 350)),\n+            self.detectorNames,\n+            self.cco.beamNames,\n+        ):\n+            with self.subTest(pupilOffset=pupilOffset, detectorPos=detectorPos, detector=detector, beam=beam):\n+                self.cco.setDetectorPos(pupilPos=pupilPos, detectorPos=detectorPos, detector=detector,\n+                                        beam=beam)\n+                initialBeamInfo = self.cco[beam]\n+                self.assertPairsAlmostEqual(initialBeamInfo.pupilPos, pupilPos, self.maxPupilPosErr)\n+                self.assertPairsAlmostEqual(initialBeamInfo.detectorPos, detectorPos,\n+                                            maxDiff=self.maxDetectorPosErr)\n+                self.cco.offsetDetectorPos(pupilOffset=pupilOffset, detectorOffset=detectorOffset,\n+                                           beam=beam)\n+                finalBeamInfo = self.cco[beam]\n+                if pupilOffset is None:\n+                    desiredPupilPos = pupilPos\n+                else:\n+                    desiredPupilPos = np.add(pupilPos, pupilOffset)\n+                if detectorOffset is None:\n+                    desiredDetectorPos = detectorPos\n+                else:\n+                    desiredDetectorPos = np.add(detectorPos, detectorOffset)\n+                self.assertPairsAlmostEqual(finalBeamInfo.pupilPos, desiredPupilPos, self.maxPupilPosErr)\n+                self.assertPairsAlmostEqual(finalBeamInfo.detectorPos, desiredDetectorPos,\n+                                            maxDiff=self.maxDetectorPosErr)\n+\n+    def testOffsetFocalFieldAngle(self):\n+        pupilPos = (3000, 4000)\n+        focalFieldAngle = (-0.03, 0.04)\n+        for pupilOffset, focalFieldAngleOffset, beam in itertools.product(\n+            (None, (0, 0), (500, -300)),\n+            (None, (0, 0), (0.02, -0.03)),\n+            self.cco.beamNames,\n+        ):\n+            with self.subTest(pupilOffset=pupilOffset, focalFieldAngleOffset=focalFieldAngleOffset,\n+                              beam=beam):\n+                self.cco.setFocalFieldAngle(pupilPos=pupilPos, focalFieldAngle=focalFieldAngle, beam=beam)\n+                initialBeamInfo = self.cco[beam]\n+                self.assertPairsAlmostEqual(initialBeamInfo.pupilPos, pupilPos, self.maxPupilPosErr)\n+                self.assertPairsAlmostEqual(initialBeamInfo.focalFieldAngle, focalFieldAngle)\n+\n+                self.cco.offsetFocalFieldAngle(pupilOffset=pupilOffset,\n+                                               focalFieldAngleOffset=focalFieldAngleOffset, beam=beam)\n+                finalBeamInfo = self.cco[beam]\n+                if pupilOffset is None:\n+                    desiredPupilPos = pupilPos\n+                else:\n+                    desiredPupilPos = np.add(pupilPos, pupilOffset)\n+                if focalFieldAngleOffset is None:\n+                    desiredFocalFieldAngle = focalFieldAngle\n+                else:\n+                    desiredFocalFieldAngle = np.add(focalFieldAngle, focalFieldAngleOffset)\n+                self.assertPairsAlmostEqual(finalBeamInfo.pupilPos, desiredPupilPos, self.maxPupilPosErr)\n+                self.assertPairsAlmostEqual(finalBeamInfo.focalFieldAngle, desiredFocalFieldAngle,\n+                                            maxDiff=self.maxFieldAngleErrRad)\n+\n+    def testOffsetFocalPlanePos(self):\n+        pupilPos = (3000, 4000)\n+        focalPlanePos = (-400, 550)\n+        for pupilOffset, focalPlaneOffset, beam in itertools.product(\n+            (None, (0, 0), (500, -300)),\n+            (None, (0, 0), (22, -350)),\n+            self.cco.beamNames,\n+        ):\n+            with self.subTest(pupilOffset=pupilOffset, focalPlanePos=focalPlanePos, beam=beam):\n+                self.cco.setFocalPlanePos(pupilPos=pupilPos, focalPlanePos=focalPlanePos, beam=beam)\n+                initialBeamInfo = self.cco[beam]\n+                self.assertPairsAlmostEqual(initialBeamInfo.pupilPos, pupilPos, self.maxPupilPosErr)\n+                self.assertPairsAlmostEqual(initialBeamInfo.focalPlanePos, focalPlanePos,\n+                                            maxDiff=self.maxFocalPlanePosErr)\n+                self.cco.offsetFocalPlanePos(pupilOffset=pupilOffset, focalPlaneOffset=focalPlaneOffset,\n+                                             beam=beam)\n+                finalBeamInfo = self.cco[beam]\n+                if pupilOffset is None:\n+                    desiredPupilPos = pupilPos\n+                else:\n+                    desiredPupilPos = np.add(pupilPos, pupilOffset)\n+                if focalPlaneOffset is None:\n+                    desiredFocalPlanePos = focalPlanePos\n+                else:\n+                    desiredFocalPlanePos = np.add(focalPlanePos, focalPlaneOffset)\n+                self.assertPairsAlmostEqual(finalBeamInfo.pupilPos, desiredPupilPos, self.maxPupilPosErr)\n+                self.assertPairsAlmostEqual(finalBeamInfo.focalPlanePos, desiredFocalPlanePos,\n+                                            maxDiff=self.maxFocalPlanePosErr)\n+\n+    def testSampleBasics(self):\n+        \"\"\"Test basic elements of the sample coordinate converter\n+        \"\"\"\n+        self.assertEqual(len(self.cco.cameraGeom), len(self.scc.detectorFracPosList))\n+        self.assertEqual(list(self.cco.cameraGeom.getNameIter()),\n+                         [\"D\" + str(i) for i in range(len(self.scc.detectorFracPosList))])\n+        self.assertEqual(len(self.cco), 6)\n+        self.assertEqual(tuple(self.cco.beamNames), (\"0\", \"1\", \"2\", \"3\", \"4\", \"5\"))\n+        self.assertEqual(self.cco.maskInfo.numHoles, 6)\n+        self.assertPairsAlmostEqual(self.cco.maskInfo.getHolePos(\"0\"), (0, 0))\n+        for beamInfo, beam in zip(self.cco, self.cco.beamNames):\n+            self.assertEqual(beamInfo.name, beam)\n+\n+    def testSetPupilFieldAngleTrivial(self):\n+        \"\"\"Test setPupilFieldAngle for the trivial case of hole 0\n+        aimed perpendicular to the center of the pupil\n+        \"\"\"\n+        self.cco.setPupilFieldAngle(pupilPos=(0, 0))\n+\n+        # the telescope should be pointed at the center of the CBP and vice-versa\n+        # NOTE: It would be nice to get better than the 0.0028\" that I measure\n+        self.assertSpherePointsAlmostEqual(self.cco.telAzAltInternal,\n+                                           SpherePoint(Vector3d(*self.cco.config.cbpPosition)),\n+                                           maxSep=0.01*arcseconds)\n+        self.assertSpherePointsAlmostEqual(self.cco.cbpAzAltInternal,\n+                                           SpherePoint(Vector3d(*(-self.cco.config.cbpPosition))),\n+                                           maxSep=0.01*arcseconds)\n+\n+        self.assertAnglesAlmostEqual(self.cco.telRotInternal, 0*degrees)\n+\n+        # beam 0 should be pointed to the center of the pupil, normal to the pupil\n+        # and land on the center of the focal plane and the center of detector D0\n+        beamInfo0 = self.cco[\"0\"]\n+        self.assertEqual(beamInfo0.name, \"0\")\n+        self.assertPairsAlmostEqual(beamInfo0.holePos, (0, 0))\n+        self.assertFalse(beamInfo0.isOnPupil)  # blocked by the central obscuration\n+        self.assertTrue(beamInfo0.isOnFocalPlane)\n+        self.assertTrue(beamInfo0.isOnDetector)\n+        self.assertPairsAlmostEqual(beamInfo0.focalPlanePos, (0, 0))\n+        self.assertPairsAlmostEqual(beamInfo0.focalFieldAngle, (0, 0))\n+        self.assertPairsAlmostEqual(beamInfo0.pupilFieldAngle, (0, 0))\n+        self.assertPairsAlmostEqual(beamInfo0.pupilPos, (0, 0), self.maxPupilPosErr)\n+        self.assertEqual(beamInfo0.detectorName, \"D0\")\n+        bboxd = Box2D(self.cco.cameraGeom[\"D0\"].getBBox())\n+        detectorCtrPos = bboxd.getCenter()\n+        detector34Pos = bboxd.getMin() + bboxd.getDimensions()*0.75\n+        self.assertPairsAlmostEqual(beamInfo0.detectorPos, detectorCtrPos)\n+        self.assertTrue(beamInfo0.isOnDetector)\n+\n+        # beam 1 should on detector D0, 3/4 of the way from LL to UR\n+        beamInfo1 = self.cco[\"1\"]\n+        self.assertTrue(beamInfo1.isOnDetector)\n+        self.assertEqual(beamInfo1.detectorName, \"D0\")\n+        self.assertPairsAlmostEqual(beamInfo1.detectorPos, detector34Pos)\n+\n+        # beam 2 should land on the center of detector D1\n+        beamInfo2 = self.cco[\"2\"]\n+        self.assertTrue(beamInfo2.isOnDetector)\n+        self.assertEqual(beamInfo2.detectorName, \"D1\")\n+        self.assertPairsAlmostEqual(beamInfo2.detectorPos, detectorCtrPos)\n+\n+        # beam 3 should on detector D1, 3/4 of the way from LL to UR\n+        beamInfo3 = self.cco[\"3\"]\n+        self.assertTrue(beamInfo3.isOnDetector)\n+        self.assertEqual(beamInfo3.detectorName, \"D1\")\n+        self.assertPairsAlmostEqual(beamInfo3.detectorPos, detector34Pos)\n+\n+        # beam 4 should land on the center of detector D2\n+        beamInfo4 = self.cco[\"4\"]\n+        self.assertTrue(beamInfo4.isOnDetector)\n+        self.assertEqual(beamInfo4.detectorName, \"D2\")\n+        self.assertPairsAlmostEqual(beamInfo4.detectorPos, detectorCtrPos, maxDiff=1e-4)\n+\n+        # beam 5 should on detector D2, 3/4 of the way from LL to UR\n+        # The measured error is 5e-7 pixels, which is small enough not to worry.\n+        # I strongly suspect it is due to inaccuracy in the inverse of the\n+        # field angle to focal plane transform.\n+        beamInfo5 = self.cco[\"5\"]\n+        self.assertTrue(beamInfo5.isOnDetector)\n+        self.assertEqual(beamInfo5.detectorName, \"D2\")\n+        self.assertPairsAlmostEqual(beamInfo5.detectorPos, detector34Pos, maxDiff=1e-4)\n+\n+    def testSetFocalFieldAngle(self):\n+        fieldAngleToFocalPlane = self.cco.cameraGeom.getTransform(FIELD_ANGLE, FOCAL_PLANE)\n+        for focalFieldAngle in ((0, 0), (0, 0.05), (-0.05, -0.03)):\n+            desiredFocalPlanePos = fieldAngleToFocalPlane.applyForward(Point2D(*focalFieldAngle))\n+            for pupilPos, beam in itertools.product(\n+                ((0, 0), (0, 5000), (-5000, 0), (5000, -5000)),\n+                self.cco.beamNames,\n+            ):\n+                with self.subTest(focalFieldAngle=focalFieldAngle, pupilPos=pupilPos, beam=beam):\n+                    self.cco.setFocalFieldAngle(pupilPos=pupilPos, focalFieldAngle=focalFieldAngle, beam=beam)\n+                    beamInfo = self.cco[beam]\n+                    self.assertPairsAlmostEqual(beamInfo.pupilPos, pupilPos, self.maxPupilPosErr)\n+                    self.assertPairsAlmostEqual(beamInfo.focalFieldAngle, focalFieldAngle,\n+                                                maxDiff=self.maxFieldAngleErrRad)\n+                    self.assertPairsAlmostEqual(beamInfo.focalPlanePos, desiredFocalPlanePos,\n+                                                maxDiff=self.maxFocalPlanePosErr)\n+                    self.checkOrientation()\n+\n+    def testSetDetectorPos(self):\n+        for detectorPos, pupilPos, detector in itertools.product(\n+            ((0, 0), (500, 1000), (25, 1850)),\n+            ((0, 0), (0, 5000), (-5000, 0), (5000, -5000)),\n+            self.detectorNames,\n+        ):\n+            with self.subTest(detectorPos=detectorPos, pupilPos=pupilPos, detector=detector):\n+                if detector is None:\n+                    desiredDetector = self.cco.config.defaultDetector\n+                else:\n+                    desiredDetector = detector\n+                for pupilPos in (\n+                ):\n+                    for beam in range(4):\n+                        self.cco.setDetectorPos(pupilPos=pupilPos, detectorPos=detectorPos, detector=detector,\n+                                                beam=beam)\n+                        beamInfo = self.cco[beam]\n+                        self.assertTrue(beamInfo.isOnDetector)\n+                        self.assertEqual(beamInfo.detectorName, desiredDetector)\n+                        self.assertPairsAlmostEqual(beamInfo.pupilPos, pupilPos,\n+                                                    maxDiff=self.maxPupilPosErr)\n+                        self.assertPairsAlmostEqual(beamInfo.detectorPos, detectorPos,\n+                                                    maxDiff=self.maxDetectorPosErr)\n+                        self.checkOrientation()\n+\n+    def testSetFocalPlanePos(self):\n+        maxErrMm = self.scc.pixelSizeMm * 0.0001\n+        for focalPlanePos, pupilPos, beam in itertools.product(\n+            ((0, 0), (500, 200), (25, 850)),\n+            ((0, 0), (0, 5000), (-5000, 0), (5000, -5000)),\n+            self.cco.beamNames,\n+        ):\n+            with self.subTest(focalPlanePos=focalPlanePos, pupilPos=pupilPos, beam=beam):\n+                self.cco.setFocalPlanePos(pupilPos=pupilPos, focalPlanePos=focalPlanePos, beam=beam)\n+                beamInfo = self.cco[beam]\n+                self.assertTrue(beamInfo.isOnFocalPlane)\n+                errMm = math.hypot(*np.subtract(beamInfo.focalPlanePos, focalPlanePos))\n+                self.assertLess(errMm, maxErrMm)\n+                self.assertPairsAlmostEqual(beamInfo.pupilPos, pupilPos, self.maxPupilPosErr)\n+\n+    def testSetPupilFieldAngleZero(self):\n+        \"\"\"Test setPupilFieldAngle for zero field angle and various points on the pupil\n+        \"\"\"\n+        for pupilPos in ((0, 5000), (-5000, 0), (5000, -5000)):\n+            with self.subTest(pupilPos=pupilPos):\n+                self.cco.setPupilFieldAngle(pupilPos=pupilPos)\n+\n+                # the telescope should be pointing in the opposite direction of the CBP\n+                telDir = self.cco.telAzAltInternal.getVector()\n+                cbpDir = self.cco.cbpAzAltInternal.getVector()\n+                negativeCbpDir = -np.array(cbpDir, dtype=float)\n+                np.testing.assert_allclose(telDir, negativeCbpDir, atol=1e-15)\n+\n+                maxDetectorPosError = 0.0001\n+\n+                # beam 0 should be pointed to the center of the pupil, normal to the pupil\n+                # and land on the center of the focal plane and the center of detector D0\n+                beamInfo0 = self.cco[\"0\"]\n+                self.assertEqual(beamInfo0.name, \"0\")\n+                self.assertPairsAlmostEqual(beamInfo0.holePos, (0, 0))\n+                self.assertTrue(beamInfo0.isOnPupil)\n+                self.assertTrue(beamInfo0.isOnFocalPlane)\n+                self.assertTrue(beamInfo0.isOnDetector)\n+                self.assertPairsAlmostEqual(beamInfo0.focalPlanePos, (0, 0), maxDiff=maxDetectorPosError)\n+                self.assertPairsAlmostEqual(beamInfo0.focalFieldAngle, (0, 0))\n+                self.assertPairsAlmostEqual(beamInfo0.pupilFieldAngle, (0, 0))\n+                self.assertPairsAlmostEqual(beamInfo0.pupilPos, pupilPos, self.maxPupilPosErr)\n+                self.assertEqual(beamInfo0.detectorName, \"D0\")\n+                bboxd = Box2D(self.cco.cameraGeom[\"D0\"].getBBox())\n+                detectorCtrPos = bboxd.getCenter()\n+                detector34Pos = bboxd.getMin() + bboxd.getDimensions()*0.75\n+                self.assertPairsAlmostEqual(beamInfo0.detectorPos, detectorCtrPos,\n+                                            maxDiff=maxDetectorPosError)\n+                self.assertTrue(beamInfo0.isOnDetector)\n+\n+                # beam 1 should on detector D0, 3/4 of the way from LL to UR\n+                beamInfo1 = self.cco[\"1\"]\n+                self.assertTrue(beamInfo1.isOnDetector)\n+                self.assertEqual(beamInfo1.detectorName, \"D0\")\n+                self.assertPairsAlmostEqual(beamInfo1.detectorPos, detector34Pos,\n+                                            maxDiff=maxDetectorPosError)\n+\n+                # beam 2 should land on the center of detector D1\n+                beamInfo2 = self.cco[\"2\"]\n+                self.assertTrue(beamInfo2.isOnDetector)\n+                self.assertEqual(beamInfo2.detectorName, \"D1\")\n+                self.assertPairsAlmostEqual(beamInfo2.detectorPos, detectorCtrPos,\n+                                            maxDiff=maxDetectorPosError)\n+\n+                # beam 3 should on detector D1, 3/4 of the way from LL to UR\n+                beamInfo3 = self.cco[\"3\"]\n+                self.assertTrue(beamInfo3.isOnDetector)\n+                self.assertEqual(beamInfo3.detectorName, \"D1\")\n+                self.assertPairsAlmostEqual(beamInfo3.detectorPos, detector34Pos,\n+                                            maxDiff=maxDetectorPosError)\n+\n+                # beam 4 should land on the center of detector D2\n+                beamInfo4 = self.cco[\"4\"]\n+                self.assertTrue(beamInfo4.isOnDetector)\n+                self.assertEqual(beamInfo4.detectorName, \"D2\")\n+                self.assertPairsAlmostEqual(beamInfo4.detectorPos, detectorCtrPos,\n+                                            maxDiff=maxDetectorPosError)\n+\n+                # beam 5 should on detector D2, 3/4 of the way from LL to UR\n+                beamInfo5 = self.cco[\"5\"]\n+                self.assertTrue(beamInfo5.isOnDetector)\n+                self.assertEqual(beamInfo5.detectorName, \"D2\")\n+                self.assertPairsAlmostEqual(beamInfo5.detectorPos, detector34Pos,\n+                                            maxDiff=maxDetectorPosError)\n+\n+    def testSetPupilFieldAngle(self):\n+        for pupilFieldAngle, pupilPos, beam in itertools.product(\n+            ((0, 0), (0, 0.05), (-0.05, -0.03)),\n+            ((0, 0), (0, 5000), (-5000, 0), (5000, -5000)),\n+            self.cco.beamNames,\n+        ):\n+            with self.subTest(pupilFieldAngle=pupilFieldAngle, pupilPos=pupilPos, beam=beam):\n+                self.cco.setPupilFieldAngle(pupilPos=pupilPos, pupilFieldAngle=pupilFieldAngle, beam=beam)\n+                beamInfo = self.cco[beam]\n+                self.assertPairsAlmostEqual(beamInfo.pupilPos, pupilPos, self.maxPupilPosErr)\n+                self.assertPairsAlmostEqual(beamInfo.pupilFieldAngle, pupilFieldAngle, maxDiff=1e-5)\n+                self.checkOrientation()\n+\n+    def testAngleProperties(self):\n+        \"\"\"Test cbpAzAltObserved, telAzAltObserved, telRotObserved\n+        and their Internal equivalents\n+        \"\"\"\n+        # set non-trivial scale and offset for all axes;\n+        # azimuth and rotator scales must be ±1 in order to handle wrap correctly;\n+        # altitude scales should be nearly 1 and altitudeoffsets should be small\n+        # to avoid hitting limits\n+        self.cco.config.cbpAzAltScale = (-1, 0.98)\n+        self.cco.config.cbpAzAltOffset = (33.2*degrees, -2.67*degrees)\n+        self.cco.config.telAzAltScale = (1, 0.95)\n+        self.cco.config.telAzAltOffset = (-31.5*degrees, 2.3*degrees)\n+        self.cco.config.telRotScale = -1\n+        self.cco.config.telRotOffset = 222.2*degrees\n+        for cbpAzAltObserved in (\n+            SpherePoint(1, 2, degrees),\n+            SpherePoint(-45, 75.2, degrees),\n+        ):\n+            self.cco.cbpAzAltObserved = cbpAzAltObserved\n+            self.assertSpherePointsAlmostEqual(self.cco.cbpAzAltObserved, cbpAzAltObserved)\n+\n+            # observed angle = internal angle * scale + offset\n+            # internal angle = (observed angle - offset) / scale\n+            predictedCbpAzAltInternal = SpherePoint(\n+                *[(cbpAzAltObserved[i] - self.cco.config.cbpAzAltOffset[i])/self.cco.config.cbpAzAltScale[i]\n+                  for i in range(2)])\n+            self.assertSpherePointsAlmostEqual(self.cco.cbpAzAltInternal, predictedCbpAzAltInternal)\n+\n+        for telAzAltObserved in (\n+            SpherePoint(-3, 5, degrees),\n+            SpherePoint(37, -.2, degrees),\n+        ):\n+            self.cco.telAzAltObserved = telAzAltObserved\n+            self.assertSpherePointsAlmostEqual(self.cco.telAzAltObserved, telAzAltObserved)\n+            predictedTelAzAltInternal = SpherePoint(\n+                *[(telAzAltObserved[i] - self.cco.config.telAzAltOffset[i])/self.cco.config.telAzAltScale[i]\n+                  for i in range(2)])\n+            self.assertSpherePointsAlmostEqual(self.cco.telAzAltInternal, predictedTelAzAltInternal)\n+\n+        for telRotObserved in (0*degrees, -32*degrees, 167*degrees):\n+            self.cco.rotAzAltObserved = telRotObserved\n+            self.assertAnglesAlmostEqual(self.cco.rotAzAltObserved, telRotObserved)\n+            predictedRotInternal = telRotObserved/self.cco.config.telRotScale - self.cco.config.telRotOffset\n+            self.assertAnglesAlmostEqual(self.cco.telRotInternal, predictedRotInternal)\n+\n+    def testInBounds(self):\n+        \"\"\"Test the telInBounds and cbpInBounds properties\n+        \"\"\"\n+        # shrink the upper altitude limits so there's somewhere to go\n+        self.cco.config.cbpAltitudeLimits = (-88*degrees, 88*degrees)\n+        self.cco.config.telAltitudeLimits = (5*degrees, 85*degrees)\n+        self.cco.setFocalPlanePos((0, 0))\n+        self.assertTrue(self.cco.cbpInBounds)\n+        self.assertTrue(self.cco.telInBounds)\n+        originalCbpAzAltObserved = self.cco.cbpAzAltObserved\n+        cbpAltLim = self.cco.config.cbpAltitudeLimits\n+        for cbpAltObserved in cbpAltLim:\n+            self.cco.cbpAzAltObserved = SpherePoint(originalCbpAzAltObserved[0], cbpAltObserved)\n+            self.assertTrue(self.cco.cbpInBounds)\n+            self.assertTrue(self.cco.telInBounds)\n+        for badCbpAltObserved in (\n+            cbpAltLim[0] - 1e-15*radians,\n+            cbpAltLim[0] - 2*degrees,\n+            cbpAltLim[1] + 1e-15*radians,\n+            cbpAltLim[1] + 2*degrees,\n+        ):\n+            self.cco.cbpAzAltObserved = SpherePoint(originalCbpAzAltObserved[0], badCbpAltObserved)\n+            self.assertFalse(self.cco.cbpInBounds)\n+            self.assertTrue(self.cco.telInBounds)\n+        self.cco.cbpAzAltObserved = originalCbpAzAltObserved\n+\n+        originalTelAzAltObserved = self.cco.telAzAltObserved\n+        telAltLim = self.cco.config.telAltitudeLimits\n+        for telAltObserved in telAltLim:\n+            self.cco.telAzAltObserved = SpherePoint(originalTelAzAltObserved[0], telAltObserved)\n+            self.assertTrue(self.cco.telInBounds)\n+            self.assertTrue(self.cco.cbpInBounds)\n+        for badTelAltObserved in (\n+            telAltLim[0] - 1e-15*radians,\n+            telAltLim[0] - 2*degrees,\n+            telAltLim[1] + 1e-15*radians,\n+            telAltLim[1] + 2*degrees,\n+        ):\n+            self.cco.telAzAltObserved = SpherePoint(originalTelAzAltObserved[0], badTelAltObserved)\n+            self.assertFalse(self.cco.telInBounds)\n+            self.assertTrue(self.cco.cbpInBounds)\n+\n+    def testHolePositionsFlipX(self):\n+        for telFlipX, cbpFlipX in itertools.product((False, True), (False, True)):\n+            with self.subTest(telFlipX=telFlipX, cbpFlipX=cbpFlipX):\n+                scc = SampleCoordinateConverter(telFlipX=telFlipX, cbpFlipX=cbpFlipX)\n+                holePositions = [scc.maskInfo.getHolePos(name) for name in scc.maskInfo.holeNames]\n+                if not (telFlipX or cbpFlipX):\n+                    unflippedHolePositions = holePositions[:]\n+                    flippedHolePositions = [(-pos[0], pos[1]) for pos in unflippedHolePositions]\n+                elif cbpFlipX:\n+                    self.assertPairListsAlmostEqual(holePositions, flippedHolePositions)\n+                else:\n+                    self.assertPairListsAlmostEqual(holePositions, unflippedHolePositions)\n+\n+    def testSetPupilFieldAngleFlipX(self):\n+        \"\"\"Test setPupilFieldAngle with varying flipX for telescope and CBP\n+        \"\"\"\n+        beam = 3  # pick a beam that is a bit off center\n+        for telFlipX, cbpFlipX in itertools.product((False, True), (False, True)):\n+            with self.subTest(telFlipX=telFlipX, cbpFlipX=cbpFlipX):\n+                # flip pupilPos and fieldAngle so that everything else is identical\n+                # e.g. all 3-D vectors; that makes debugging easier if the test fails\n+                unflippedPupilPos = (5000, -5000)\n+                unflippedPupilFieldAngle = (0.1, 0.1)","path":"tests/test_coordinateConverter.py","position":497,"original_position":497,"commit_id":"49c8eb849defe97aeb275425bfc2465f0e77d09a","original_commit_id":"460ebd723cb4d982e0763db39f97516ec95b3163","user":{"login":"mfisherlevine","id":18741538,"node_id":"MDQ6VXNlcjE4NzQxNTM4","avatar_url":"https://avatars1.githubusercontent.com/u/18741538?v=4","gravatar_id":"","url":"https://api.github.com/users/mfisherlevine","html_url":"https://github.com/mfisherlevine","followers_url":"https://api.github.com/users/mfisherlevine/followers","following_url":"https://api.github.com/users/mfisherlevine/following{/other_user}","gists_url":"https://api.github.com/users/mfisherlevine/gists{/gist_id}","starred_url":"https://api.github.com/users/mfisherlevine/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/mfisherlevine/subscriptions","organizations_url":"https://api.github.com/users/mfisherlevine/orgs","repos_url":"https://api.github.com/users/mfisherlevine/repos","events_url":"https://api.github.com/users/mfisherlevine/events{/privacy}","received_events_url":"https://api.github.com/users/mfisherlevine/received_events","type":"User","site_admin":false},"body":"Is there any reason not to change this tuple and the one above to have different values for their [0] and [1] elements to also check axis inversion? _i.e._ (5000, -4000) and (0.1, 0.2) for example? Doesn't matter, but just a thought.","created_at":"2018-06-28T15:33:49Z","updated_at":"2018-06-28T15:37:35Z","html_url":"https://github.com/lsst/cbp/pull/1#discussion_r198887170","pull_request_url":"https://api.github.com/repos/lsst/cbp/pulls/1","author_association":"NONE","_links":{"self":{"href":"https://api.github.com/repos/lsst/cbp/pulls/comments/198887170"},"html":{"href":"https://github.com/lsst/cbp/pull/1#discussion_r198887170"},"pull_request":{"href":"https://api.github.com/repos/lsst/cbp/pulls/1"}}},"pull_request":{"url":"https://api.github.com/repos/lsst/cbp/pulls/1","id":183864574,"node_id":"MDExOlB1bGxSZXF1ZXN0MTgzODY0NTc0","html_url":"https://github.com/lsst/cbp/pull/1","diff_url":"https://github.com/lsst/cbp/pull/1.diff","patch_url":"https://github.com/lsst/cbp/pull/1.patch","issue_url":"https://api.github.com/repos/lsst/cbp/issues/1","number":1,"state":"open","locked":false,"title":"DM-14161: Write coordinate converter for collimated beam projector","user":{"login":"r-owen","id":874176,"node_id":"MDQ6VXNlcjg3NDE3Ng==","avatar_url":"https://avatars0.githubusercontent.com/u/874176?v=4","gravatar_id":"","url":"https://api.github.com/users/r-owen","html_url":"https://github.com/r-owen","followers_url":"https://api.github.com/users/r-owen/followers","following_url":"https://api.github.com/users/r-owen/following{/other_user}","gists_url":"https://api.github.com/users/r-owen/gists{/gist_id}","starred_url":"https://api.github.com/users/r-owen/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/r-owen/subscriptions","organizations_url":"https://api.github.com/users/r-owen/orgs","repos_url":"https://api.github.com/users/r-owen/repos","events_url":"https://api.github.com/users/r-owen/events{/privacy}","received_events_url":"https://api.github.com/users/r-owen/received_events","type":"User","site_admin":false},"body":"","created_at":"2018-04-24T21:07:23Z","updated_at":"2018-06-28T15:37:35Z","closed_at":null,"merged_at":null,"merge_commit_sha":"c0c5637cfec8dbdcfd0d32cb29e14d8d21390760","assignee":null,"assignees":[],"requested_reviewers":[],"requested_teams":[],"labels":[],"milestone":null,"commits_url":"https://api.github.com/repos/lsst/cbp/pulls/1/commits","review_comments_url":"https://api.github.com/repos/lsst/cbp/pulls/1/comments","review_comment_url":"https://api.github.com/repos/lsst/cbp/pulls/comments{/number}","comments_url":"https://api.github.com/repos/lsst/cbp/issues/1/comments","statuses_url":"https://api.github.com/repos/lsst/cbp/statuses/49c8eb849defe97aeb275425bfc2465f0e77d09a","head":{"label":"lsst:tickets/DM-14161","ref":"tickets/DM-14161","sha":"49c8eb849defe97aeb275425bfc2465f0e77d09a","user":{"login":"lsst","id":1134504,"node_id":"MDEyOk9yZ2FuaXphdGlvbjExMzQ1MDQ=","avatar_url":"https://avatars1.githubusercontent.com/u/1134504?v=4","gravatar_id":"","url":"https://api.github.com/users/lsst","html_url":"https://github.com/lsst","followers_url":"https://api.github.com/users/lsst/followers","following_url":"https://api.github.com/users/lsst/following{/other_user}","gists_url":"https://api.github.com/users/lsst/gists{/gist_id}","starred_url":"https://api.github.com/users/lsst/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/lsst/subscriptions","organizations_url":"https://api.github.com/users/lsst/orgs","repos_url":"https://api.github.com/users/lsst/repos","events_url":"https://api.github.com/users/lsst/events{/privacy}","received_events_url":"https://api.github.com/users/lsst/received_events","type":"Organization","site_admin":false},"repo":{"id":127493410,"node_id":"MDEwOlJlcG9zaXRvcnkxMjc0OTM0MTA=","name":"cbp","full_name":"lsst/cbp","owner":{"login":"lsst","id":1134504,"node_id":"MDEyOk9yZ2FuaXphdGlvbjExMzQ1MDQ=","avatar_url":"https://avatars1.githubusercontent.com/u/1134504?v=4","gravatar_id":"","url":"https://api.github.com/users/lsst","html_url":"https://github.com/lsst","followers_url":"https://api.github.com/users/lsst/followers","following_url":"https://api.github.com/users/lsst/following{/other_user}","gists_url":"https://api.github.com/users/lsst/gists{/gist_id}","starred_url":"https://api.github.com/users/lsst/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/lsst/subscriptions","organizations_url":"https://api.github.com/users/lsst/orgs","repos_url":"https://api.github.com/users/lsst/repos","events_url":"https://api.github.com/users/lsst/events{/privacy}","received_events_url":"https://api.github.com/users/lsst/received_events","type":"Organization","site_admin":false},"private":false,"html_url":"https://github.com/lsst/cbp","description":"Code for using the Collimated Beam Projector","fork":false,"url":"https://api.github.com/repos/lsst/cbp","forks_url":"https://api.github.com/repos/lsst/cbp/forks","keys_url":"https://api.github.com/repos/lsst/cbp/keys{/key_id}","collaborators_url":"https://api.github.com/repos/lsst/cbp/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/lsst/cbp/teams","hooks_url":"https://api.github.com/repos/lsst/cbp/hooks","issue_events_url":"https://api.github.com/repos/lsst/cbp/issues/events{/number}","events_url":"https://api.github.com/repos/lsst/cbp/events","assignees_url":"https://api.github.com/repos/lsst/cbp/assignees{/user}","branches_url":"https://api.github.com/repos/lsst/cbp/branches{/branch}","tags_url":"https://api.github.com/repos/lsst/cbp/tags","blobs_url":"https://api.github.com/repos/lsst/cbp/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/lsst/cbp/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/lsst/cbp/git/refs{/sha}","trees_url":"https://api.github.com/repos/lsst/cbp/git/trees{/sha}","statuses_url":"https://api.github.com/repos/lsst/cbp/statuses/{sha}","languages_url":"https://api.github.com/repos/lsst/cbp/languages","stargazers_url":"https://api.github.com/repos/lsst/cbp/stargazers","contributors_url":"https://api.github.com/repos/lsst/cbp/contributors","subscribers_url":"https://api.github.com/repos/lsst/cbp/subscribers","subscription_url":"https://api.github.com/repos/lsst/cbp/subscription","commits_url":"https://api.github.com/repos/lsst/cbp/commits{/sha}","git_commits_url":"https://api.github.com/repos/lsst/cbp/git/commits{/sha}","comments_url":"https://api.github.com/repos/lsst/cbp/comments{/number}","issue_comment_url":"https://api.github.com/repos/lsst/cbp/issues/comments{/number}","contents_url":"https://api.github.com/repos/lsst/cbp/contents/{+path}","compare_url":"https://api.github.com/repos/lsst/cbp/compare/{base}...{head}","merges_url":"https://api.github.com/repos/lsst/cbp/merges","archive_url":"https://api.github.com/repos/lsst/cbp/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/lsst/cbp/downloads","issues_url":"https://api.github.com/repos/lsst/cbp/issues{/number}","pulls_url":"https://api.github.com/repos/lsst/cbp/pulls{/number}","milestones_url":"https://api.github.com/repos/lsst/cbp/milestones{/number}","notifications_url":"https://api.github.com/repos/lsst/cbp/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/lsst/cbp/labels{/name}","releases_url":"https://api.github.com/repos/lsst/cbp/releases{/id}","deployments_url":"https://api.github.com/repos/lsst/cbp/deployments","created_at":"2018-03-31T02:52:58Z","updated_at":"2018-04-24T21:01:26Z","pushed_at":"2018-06-28T14:49:17Z","git_url":"git://github.com/lsst/cbp.git","ssh_url":"git@github.com:lsst/cbp.git","clone_url":"https://github.com/lsst/cbp.git","svn_url":"https://github.com/lsst/cbp","homepage":null,"size":332,"stargazers_count":0,"watchers_count":0,"language":null,"has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"forks_count":2,"mirror_url":null,"archived":false,"open_issues_count":1,"license":null,"forks":2,"open_issues":1,"watchers":0,"default_branch":"master"}},"base":{"label":"lsst:master","ref":"master","sha":"7d2901f2b50c326fdbf509b4d22eb7ca59877e1f","user":{"login":"lsst","id":1134504,"node_id":"MDEyOk9yZ2FuaXphdGlvbjExMzQ1MDQ=","avatar_url":"https://avatars1.githubusercontent.com/u/1134504?v=4","gravatar_id":"","url":"https://api.github.com/users/lsst","html_url":"https://github.com/lsst","followers_url":"https://api.github.com/users/lsst/followers","following_url":"https://api.github.com/users/lsst/following{/other_user}","gists_url":"https://api.github.com/users/lsst/gists{/gist_id}","starred_url":"https://api.github.com/users/lsst/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/lsst/subscriptions","organizations_url":"https://api.github.com/users/lsst/orgs","repos_url":"https://api.github.com/users/lsst/repos","events_url":"https://api.github.com/users/lsst/events{/privacy}","received_events_url":"https://api.github.com/users/lsst/received_events","type":"Organization","site_admin":false},"repo":{"id":127493410,"node_id":"MDEwOlJlcG9zaXRvcnkxMjc0OTM0MTA=","name":"cbp","full_name":"lsst/cbp","owner":{"login":"lsst","id":1134504,"node_id":"MDEyOk9yZ2FuaXphdGlvbjExMzQ1MDQ=","avatar_url":"https://avatars1.githubusercontent.com/u/1134504?v=4","gravatar_id":"","url":"https://api.github.com/users/lsst","html_url":"https://github.com/lsst","followers_url":"https://api.github.com/users/lsst/followers","following_url":"https://api.github.com/users/lsst/following{/other_user}","gists_url":"https://api.github.com/users/lsst/gists{/gist_id}","starred_url":"https://api.github.com/users/lsst/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/lsst/subscriptions","organizations_url":"https://api.github.com/users/lsst/orgs","repos_url":"https://api.github.com/users/lsst/repos","events_url":"https://api.github.com/users/lsst/events{/privacy}","received_events_url":"https://api.github.com/users/lsst/received_events","type":"Organization","site_admin":false},"private":false,"html_url":"https://github.com/lsst/cbp","description":"Code for using the Collimated Beam Projector","fork":false,"url":"https://api.github.com/repos/lsst/cbp","forks_url":"https://api.github.com/repos/lsst/cbp/forks","keys_url":"https://api.github.com/repos/lsst/cbp/keys{/key_id}","collaborators_url":"https://api.github.com/repos/lsst/cbp/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/lsst/cbp/teams","hooks_url":"https://api.github.com/repos/lsst/cbp/hooks","issue_events_url":"https://api.github.com/repos/lsst/cbp/issues/events{/number}","events_url":"https://api.github.com/repos/lsst/cbp/events","assignees_url":"https://api.github.com/repos/lsst/cbp/assignees{/user}","branches_url":"https://api.github.com/repos/lsst/cbp/branches{/branch}","tags_url":"https://api.github.com/repos/lsst/cbp/tags","blobs_url":"https://api.github.com/repos/lsst/cbp/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/lsst/cbp/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/lsst/cbp/git/refs{/sha}","trees_url":"https://api.github.com/repos/lsst/cbp/git/trees{/sha}","statuses_url":"https://api.github.com/repos/lsst/cbp/statuses/{sha}","languages_url":"https://api.github.com/repos/lsst/cbp/languages","stargazers_url":"https://api.github.com/repos/lsst/cbp/stargazers","contributors_url":"https://api.github.com/repos/lsst/cbp/contributors","subscribers_url":"https://api.github.com/repos/lsst/cbp/subscribers","subscription_url":"https://api.github.com/repos/lsst/cbp/subscription","commits_url":"https://api.github.com/repos/lsst/cbp/commits{/sha}","git_commits_url":"https://api.github.com/repos/lsst/cbp/git/commits{/sha}","comments_url":"https://api.github.com/repos/lsst/cbp/comments{/number}","issue_comment_url":"https://api.github.com/repos/lsst/cbp/issues/comments{/number}","contents_url":"https://api.github.com/repos/lsst/cbp/contents/{+path}","compare_url":"https://api.github.com/repos/lsst/cbp/compare/{base}...{head}","merges_url":"https://api.github.com/repos/lsst/cbp/merges","archive_url":"https://api.github.com/repos/lsst/cbp/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/lsst/cbp/downloads","issues_url":"https://api.github.com/repos/lsst/cbp/issues{/number}","pulls_url":"https://api.github.com/repos/lsst/cbp/pulls{/number}","milestones_url":"https://api.github.com/repos/lsst/cbp/milestones{/number}","notifications_url":"https://api.github.com/repos/lsst/cbp/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/lsst/cbp/labels{/name}","releases_url":"https://api.github.com/repos/lsst/cbp/releases{/id}","deployments_url":"https://api.github.com/repos/lsst/cbp/deployments","created_at":"2018-03-31T02:52:58Z","updated_at":"2018-04-24T21:01:26Z","pushed_at":"2018-06-28T14:49:17Z","git_url":"git://github.com/lsst/cbp.git","ssh_url":"git@github.com:lsst/cbp.git","clone_url":"https://github.com/lsst/cbp.git","svn_url":"https://github.com/lsst/cbp","homepage":null,"size":332,"stargazers_count":0,"watchers_count":0,"language":null,"has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"forks_count":2,"mirror_url":null,"archived":false,"open_issues_count":1,"license":null,"forks":2,"open_issues":1,"watchers":0,"default_branch":"master"}},"_links":{"self":{"href":"https://api.github.com/repos/lsst/cbp/pulls/1"},"html":{"href":"https://github.com/lsst/cbp/pull/1"},"issue":{"href":"https://api.github.com/repos/lsst/cbp/issues/1"},"comments":{"href":"https://api.github.com/repos/lsst/cbp/issues/1/comments"},"review_comments":{"href":"https://api.github.com/repos/lsst/cbp/pulls/1/comments"},"review_comment":{"href":"https://api.github.com/repos/lsst/cbp/pulls/comments{/number}"},"commits":{"href":"https://api.github.com/repos/lsst/cbp/pulls/1/commits"},"statuses":{"href":"https://api.github.com/repos/lsst/cbp/statuses/49c8eb849defe97aeb275425bfc2465f0e77d09a"}},"author_association":"CONTRIBUTOR"}}
{"actor":{"display_login":"mfisherlevine"}}