#!/usr/bin/env -S python -W ignore

from math import pi,sin,cos,acos,asin,tan,atan2,sqrt,exp
import time

def DEG2RAD(deg):
    return deg*pi/180
def RAD2DEG(rad):
    return rad*180/pi


class Astrometry:
    A,B,guideA,guideB = 0.,0.,0.,0.
    def setABCoefficients(self,temp=20):
        wavelength = self.WAVELENGTH/10000.
        guideWave = self.GUIDEWAVELENGTH/10000.
        latitude = DEG2RAD(self.sitePars["KPNO_LAT"])

        self.A,self.B = refco(self.sitePars["KPNO_ALT"],temp+273.15,self.sitePars["KPNO_ATM_PRES"],self.sitePars["KPNO_HUMIDITY"],wavelength,latitude,self.sitePars["LAPSE_RATE"],self.sitePars["REFRACT_PREC"])

        self.guideA,self.guideB = refco(self.sitePars["KPNO_ALT"],temp+273.15,self.sitePars["KPNO_ATM_PRES"],self.sitePars["KPNO_HUMIDITY"],guideWave,latitude,self.sitePars["LAPSE_RATE"],self.sitePars["REFRACT_PREC"])

    def getRefractionOffsets(self,inRA,inDec,siderealTime,A,B):
        ra = inRA
        dec = inDec
        hourAngle = (siderealTime*15*pi/180)-inRA
        latitude = DEG2RAD(self.sitePars["KPNO_LAT"])
        cosLat = cos(latitude)
        sinLat = sin(latitude)

        cos_zdist = sin(dec)*sinLat+cos(dec)*cosLat*cos(hourAngle)
        airmass = 1./cos_zdist
        zdist = acos(cos_zdist)

        z_refract = refz(zdist,A,B)
        az = atan2(sin(hourAngle),cos(hourAngle)*sinLat-tan(dec)*cosLat)
        az += pi

        newDec = asin(sinLat*cos(z_refract)+cosLat*sin(z_refract)*cos(az))
        dDec = newDec-dec

        sinHa = -sin(az)*sin(z_refract)
        cosHa = (cos(z_refract)-sin(newDec)*sinLat)/cosLat
        newHa = atan2(sinHa,cosHa)
        dRA = hourAngle-newHa
        return dRA,dDec,airmass

    def refractCoords(self,ra,dec,cam=False):
        if cam:
            a,b = self.guideA,self.guideB
        else:
            a,b = self.A,self.B
        timeInterval = self.EXPTIME/self.sitePars["REFRACT_PTS"]
        startSideTime = self.LST-self.EXPTIME/2+timeInterval/2
        sumRA,sumDec,sumWeight = 0.,0.,0.
        for i in range(self.sitePars["REFRACT_PTS"]):
            obsSideTime = startSideTime+i*timeInterval
            dRA,dDec,airmass = self.getRefractionOffsets(ra,dec,obsSideTime,a,b)
            sumRA += dRA/airmass
            sumDec += dDec/airmass
            sumWeight += 1./airmass
        return ra+sumRA/sumWeight,dec+sumDec/sumWeight

    def rotatePoint(self,x,y):
        A = DEG2RAD(self.PA)-pi/2
        xout = x*sin(A)+y*cos(A)
        yout = -x*cos(A)+y*sin(A)
        return xout,yout

    def projectAndCorrect(self,ra,dec):
        arcsec2rad = pi/180./3600
        #from astropy.wcs import WCS
        #W = WCS({"CRVAL1":self.REFRA*180/pi,"CRVAL2":self.REFDEC*180/pi,"CD1_1":1,"CD1_2":0,"CD2_1":0,"CD2_2":1.,"CTYPE1":"RA---TAN","CTYPE2":"DEC--TAN"})
        X,Y = self.WCS.all_world2pix([ra*180/pi],[dec*180/pi],1)
        X = X[0]*pi/180
        Y = Y[0]*pi/180
        dist = 1.+self.sitePars["WIYN_PINCUSHION"]*(X*X+Y*Y)
        dist /= self.sitePars["WIYN_SCALE"]*arcsec2rad
        X *= dist
        Y *= dist
        return self.rotatePoint(X,Y)

    def skyToPlate(self,inRA,inDec):
        pra,pdec = inRA*pi/180,inDec*pi/180
        wra,wdec = self.refractCoords(pra,pdec,True)
        xcam,ycam = self.projectAndCorrect(wra,wdec)
        wra,wdec = self.refractCoords(pra,pdec)
        xspec,yspec = self.projectAndCorrect(wra,wdec)
        return xcam,ycam,xspec,yspec

    def plateToSky(self,x,y):
        angle = DEG2RAD(self.PA)
        rotC = cos(angle)*self.sitePars["WIYN_SCALE"]/3600.
        rotS = sin(angle)*self.sitePars["WIYN_SCALE"]/3600.
        cosDec = cos(self.FIELDDEC*pi/180)
        sinDec = sin(self.FIELDDEC*pi/180)

        xi = x*-rotC+y*-rotS
        eta = x*rotS+y*-rotC

        # Calculate the intermediate coordinates
        R = (xi**2+eta**2)**0.5
        phi = atan2(xi,-eta)

        # Determine theta from R(theta) (Eqn 68 from WCS paper II).
        # Solution can be found at:
        # https://www.wolframalpha.com/input?i=Solve%5Bx%2BC*x%5E3%3D%3DR%2Cx%5D
        C = self.sitePars["WIYN_PINCUSHION"]*(pi/180)**2
        rootTerm = (sqrt(3*(27*C*R*R+4)*C**3) + 9*R*C*C)**(1/3)
        nom = (2**(1/3))*rootTerm**2-2*C*3**(1/3)
        dom = (6**(2/3))*C*rootTerm
        theta = 90-nom/dom

        # Do the de-projection. See Eqn 2, Section 2.3 of WCS paper II
        #  (the -pi comes from the LONPOLE discussion in the previous section).
        sinPhi = sin(phi-pi)
        cosPhi = cos(phi-pi)
        sinTheta = sin(theta*pi/180)
        cosTheta = cos(theta*pi/180)
        arg1 = sinTheta*cosDec-cosTheta*sinDec*cosPhi
        arg2 = -1*cosTheta*sinPhi
        alpha = self.FIELDRA+atan2(arg2,arg1)*180/pi
        delta = asin(sinTheta*sinDec+cosTheta*cosDec*cosPhi)*180/pi
        return alpha,delta


RADIANS2DEGREES = 180./pi
DEG93_IN_RADIANS = 93/RADIANS2DEGREES
MOLAR_GAS_CONSTANT = 8314.32
DRY_AIR_MOL_WEIGHT = 28.9644
WATER_VAPOUR_MOL_WEIGHT = 18.0152
EARTH_RADIUS = 6378120.
DELTA = 18.36
TROPOPAUSE_HEIGHT = 11000.
RE_HEIGHT_LIMIT = 80000.
MAX_STRIPS = 16384

def drange(angle):
    result = angle%(2*pi)
    if abs(result)>=pi:
        result -= 2*pi*result/abs(result)
    return result

def atmt(r0,t0,alpha,gamm2,delm2,c1,c2,c3,c4,c5,c6,r):
    t = max(min(t0-alpha*(r-r0),320.),100.)
    tt0 = t/t0
    tt0gm2 = tt0**gamm2
    tt0dm2 = tt0**delm2
    dn = 1+(c1*tt0gm2-(c2-c5/t)*tt0dm2)*tt0
    rdndr = r*(-c3*tt0gm2 + (c4-c6/tt0)*tt0dm2)
    return t,dn,rdndr

def atms(rt,tt,dnt,gamal,r):
    b = gamal/tt
    w = (dnt-1)*exp(-b*(r-rt))
    return 1+w,-r*b*w

refraction_integrand = lambda dn,rdndr: rdndr/(dn+rdndr)

def refro(ozd,oh,atk,apm,arh,wl,phi,tlr,eps):
    zobs1 = drange(ozd)
    zobs2 = min(abs(zobs1),DEG93_IN_RADIANS)
    hm_ok = min(max(oh,-1e3),RE_HEIGHT_LIMIT)
    tdk_ok = min(max(atk,100.),500.)
    pmb_ok = min(max(apm,0.),1e4)
    rh_ok = min(max(arh,0.),1.)
    wl_ok = max(wl,0.1)
    alpha = min(max(abs(tlr),0.001),0.01)
    tolerance = min(max(abs(eps),1e-12),1.)/2.

    optic = wl_ok <=100.
    wl_squared = wl_ok*wl_ok
    gb = 9.784*(1.-0.0026*cos(phi+phi)-0.00000028*hm_ok)
    a = (287.6155+(1.62887+0.01360/wl_squared)/wl_squared)*273.15e-6/1013.25 if optic else 77.689e-6
    gamal = (gb*DRY_AIR_MOL_WEIGHT)/MOLAR_GAS_CONSTANT
    gamma = gamal/alpha
    gamm2 = gamma-2
    delm2 = DELTA-2
    tdc = tdk_ok-273.15
    psat = (1.+pmb_ok*(4.5e-6+6e-10*tdc*tdc))*10**((0.7859+0.03477*tdc)/(1+0.00412*tdc))
    pwo = 0 if pmb_ok<=0 else rh_ok*psat/(1.-(1-rh_ok)*psat/pmb_ok)
    w = pwo*(1-WATER_VAPOUR_MOL_WEIGHT/DRY_AIR_MOL_WEIGHT)*gamma/(DELTA-gamma)
    c1 = a*(pmb_ok+w)/tdk_ok
    c2 = (a*w+(4.8746e-6*optic+6.3938e-6)*pwo)/tdk_ok
    c3 = (gamma-1)*alpha*c1/tdk_ok
    c4 = (DELTA-1.)*alpha*c2/tdk_ok
    c5 = 0 if optic else 375463e-6*pwo/tdk_ok
    c6 = 0 if optic else c5*delm2*alpha/(tdk_ok*tdk_ok)

    r0 = EARTH_RADIUS+hm_ok
    temp0,dn0,rdndr0 = atmt(r0,tdk_ok,alpha,gamm2,delm2,c1,c2,c3,c4,c5,c6,r0)
    sk0 = dn0*r0*sin(zobs2)
    f0 = refraction_integrand(dn0,rdndr0)

    rt = EARTH_RADIUS + max(TROPOPAUSE_HEIGHT,hm_ok)
    tt,dnt,rdndrt = atmt(r0,tdk_ok,alpha,gamm2,delm2,c1,c2,c3,c4,c5,c6,rt)
    sine = sk0/(rt*dnt)
    zt = atan2(sine,sqrt(max(1-sine*sine,0)))
    ft = refraction_integrand(dnt,rdndrt)

    dnts,rdndrp = atms(rt,tt,dnt,gamal,rt)
    sine = sk0/(rt*dnts)
    zts = atan2(sine,sqrt(max(1-sine*sine,0)))
    fts = refraction_integrand(dnts,rdndrp)

    rs = EARTH_RADIUS+RE_HEIGHT_LIMIT
    dns,rdndrs = atms(rt,tt,dnt,gamal,rs)
    sine = sk0/(rs*dns)
    zs = atan2(sine,sqrt(max(1-sine*sine,0)))
    fs = refraction_integrand(dns,rdndrs)

    refp,reft = 0.,0.
    for k in [0,1]:
        ref_old = 1.
        num_strips = 8
        if k==0:
            z0 = zobs2
            z_range = zt-z0
            fb = f0
            ff = ft
        else:
            z0 = zts
            z_range = zs-z0
            fb = fts
            ff = fs
        f_odd,f_even = 0.,0.
        step = 1
        while 1:
            h = z_range/num_strips
            r = r0 if k==0 else rt
            for i in range(1,num_strips,step):
                sine_zd = sin(z0+h*i)
                if sine_zd>1e-20:
                    ww = sk0/sine_zd
                    rg = r
                    dr = 1e6
                    for j in range(4):
                        if abs(dr)<=1:
                            break
                        if k==0:
                            tg,dn,rdndr = atmt(r0,tdk_ok,alpha,gamm2,delm2,c1,c2,c3,c4,c5,c6,rg)
                        else:
                            dn,rdndr = atms(rt,tt,dnt,gamal,rg)
                        dr = (rg*dn-ww)/(dn+rdndr)
                        rg = rg-dr
                    r = rg
                if k==0:
                    t,dn,rdndr = atmt(r0,tdk_ok,alpha,gamm2,delm2,c1,c2,c3,c4,c5,c6,r)
                else:
                    dn,rdndr = atms(rt,tt,dnt,gamal,r)
                f = refraction_integrand(dn,rdndr)
                if step==1 and i%2==0:
                    f_even += f
                else:
                    f_odd += f
            refp = h*(fb+4*f_odd+2*f_even+ff)/3.
            if abs(refp-ref_old)>tolerance and num_strips<MAX_STRIPS:
                ref_old = refp
                num_strips += num_strips
                f_even = f_even+f_odd
                f_odd = 0.
                step = 2
            else:
                if k==0:
                    reft = refp
                break
    result = reft+refp
    if zobs1<0:
        result *= -1
    return result

def refco(oh,atk,apm,arh,wl,phi,tlr,eps):
    ATAN_1 = 0.7853981633974483
    ATAN_4 = 1.325817663668033

    r1 = refro(ATAN_1, oh, atk, apm, arh, wl, phi, tlr, eps)
    r2 = refro(ATAN_4, oh, atk, apm, arh, wl, phi, tlr, eps)
    refa = (64*r1-r2)/60
    refb = (r2-4*r1)/60
    return refa,refb

C1,C2,C3,C4,C5 = 0.55445,-0.01133,0.00202,0.28385,0.02390
ZD_THRESHOLD83 = 83./RADIANS2DEGREES
REF83 = (C1 + C2*7.0 + C3*49.0) / (1.0 + C4*7.0 + C5*49.0)
def refz(zu,refa,refb):
    zu1 = min(zu,ZD_THRESHOLD83)
    zl = zu1
    sine = sin(zl)
    cosine = cos(zl)
    tangent = sine/cosine
    tangent_sqr = tangent*tangent
    tangent_cube = tangent*tangent_sqr
    zl = zl-(refa*tangent + refb*tangent_cube)/(1.+(refa+3*refb*tangent_sqr)/(cosine*cosine))

    sine = sin(zl)
    cosine = cos(zl)
    tangent = sine/cosine
    tangent_sqr = tangent*tangent
    tangent_cube = tangent*tangent_sqr
    ref = zu1-zl+(zl-zu1+refa*tangent+refb*tangent_cube)/(1+(refa+3*refb*tangent_sqr)/(cosine*cosine))
    if zu>zu1:
        E = 90.-min(DEG93_IN_RADIANS,zu*RADIANS2DEGREES)
        E2 = E*E
        ref = (ref/REF83)*(C1+C2*E+C3*E2)/(1+C4*E+C5*E2)
    return zu-ref

import random
import time
from math import cos,sin,pi,atan2,sqrt,log
from PyQt6.QtCore import Qt,pyqtSlot,pyqtSignal
import shapely
from multiprocessing import set_start_method

PROCESS_START_METHOD = "fork"
set_start_method(PROCESS_START_METHOD)

class MPHelper:
    catalog = None
    idmap = None
    fiberGeometries = None
    footprints = None
    HydraConfig = None
MPH = MPHelper()

"""
Function to create matrix entries.

This is in global scope to allow pickling for multi-processing.

args -- either a tuple including an optID and MPHelper data structure
          or just an optID (in which case the global MPH structure is
          used)
"""
def populateMatrixEntries(args):
    if type(args)==tuple:
        optID,indata = args
    else:
        optID = args
        indata = MPH
    buttonDiameter = indata.HydraConfig["FIBERBUTTON_RADIUS"]*2
    objid = indata.idmap[optID]
    x = indata.catalog[objid]["x"]
    y = indata.catalog[objid]["y"]
    footprint = indata.footprints[optID]

    geometries = indata.fiberGeometries[optID]
    Nfibers = len(geometries)
    entries = []
    for optID2 in range(optID+1,len(indata.idmap)):
        footprint2 = indata.footprints[optID2]
        objid2 = indata.idmap[optID2]
        x0,y0 = indata.catalog[objid2]["x"],indata.catalog[objid2]["y"]
        geometries2 = indata.fiberGeometries[optID2]
        entry = getMatrixEntry(x,y,footprint,geometries,optID,optID2,x0,y0,footprint2,geometries2,buttonDiameter)
        entries.append(entry)
    return entries

def getMatrixEntry(x,y,footprint,geometries,optID,optID2,x0,y0,footprint2,geometries2,buttonDiameter):
    # Buttons always collide
    if sqrt((x-x0)*(x-x0)+(y-y0)*(y-y0))<buttonDiameter:
        return [0]
        # Fibers never overlap
    if not shapely.intersects(footprint,footprint2):
        return [1]
    overlaps = [0]*len(geometries)
    Aindex = []
    for A,GA in enumerate(geometries):
        if GA is None:
            continue
        if shapely.intersects(GA,footprint2):
            Aindex.append(A)
    Bindex = [] 
    for B,GB in enumerate(geometries2):
        if GB is None:
            continue
        if shapely.intersects(GB,footprint):
            Bindex.append(B)
    for A in Aindex:
        matches = 0
        fiberA = geometries[A]
        for B in Bindex:
            if shapely.intersects(fiberA,geometries2[B]):
                matches |= 1<<B
        overlaps[A] = matches
    return [2,overlaps]


class CollisionMatrix:

    updateProgressSignal = pyqtSignal(int)
    printMessageSignal = pyqtSignal(str)
    REOPT = False
    buttonX = None
    buttonY = None
    INITIALIZING = True

    def populateMatrixEntries(self,optID):
        MPH = MPHelper()
        MPH.catalog = self.catalog
        MPH.idmap = self.idmap
        MPH.fiberGeometries = self.fiberGeometries
        MPH.footprints = self.footprints
        MPH.HydraConfig = self.HydraConfig
        return populateMatrixEntries((optID,MPH))

    def getMatrixEntry(self,optID,optID2):
        objid = self.idmap[optID]
        x = self.catalog[objid]["x"]
        y = self.catalog[objid]["y"]
        footprint = self.footprints[optID]
        geometries = self.fiberGeometries[optID]
        footprint2 = self.footprints[optID2]
        objid2 = self.idmap[optID2]
        x0,y0 = self.catalog[objid2]["x"],self.catalog[objid2]["y"]
        geometries2 = self.fiberGeometries[optID2]
        return getMatrixEntry(x,y,footprint,geometries,optID,optID2,x0,y0,footprint2,geometries2,self.HydraConfig["FIBERBUTTON_RADIUS"]*2)

    def setButtons(self):
        rad = self.HydraConfig["FIBERBUTTON_RADIUS"]
        npts = self.HydraConfig["FIBERBUTTON_NCIRC"]
        self.buttonX = [rad*cos(i*2*pi/npts) for i in range(npts)]
        self.buttonY = [rad*sin(i*2*pi/npts) for i in range(npts)]

    def getFiber(self,fibid,coords=None):
        if self.buttonX is None:
            self.setButtons()
        sfibid = str(fibid)
        theta = self.FiberDB[sfibid]["theta"]
        fibx = self.FiberDB[sfibid]["xpivot"]
        fiby = self.FiberDB[sfibid]["ypivot"]
        if coords is None:
            x = self.FiberDB[sfibid]["xpark"]
            y = self.FiberDB[sfibid]["ypark"]
            angle = theta
            button = shapely.Polygon([(bx+x,by+y) for bx,by in zip(self.buttonX,self.buttonY)])
        else:
            if len(coords)==4:
                x,y,angle,button = coords
            else:
                x,y = coords
                angle = atan2(y,x)
                if angle<0:
                    angle += 2*pi
                button = shapely.Polygon([(bx+x,by+y) for bx,by in zip(self.buttonX,self.buttonY)])
        extent = sqrt((fibx-x)*(fibx-x)+(fiby-y)*(fiby-y))
        if extent>self.HydraConfig["MAXEXTEND"]:
            return None
        originDistance = sqrt(x*x+y*y)
        phi = angle-theta
        deflection = originDistance*sin(phi)
        originRadialDistance = originDistance*cos(phi)
        pivotRadialDistance = self.HydraConfig["PIVOT"]-originRadialDistance
        psi = atan2(deflection,pivotRadialDistance)
        if abs(psi)>self.HydraConfig["MAXANGLE"]:
            return None
        npnts = self.HydraConfig["FIBERTUBE_NSEGMENTS"]*2+2
        tx,ty = [0]*10,[0]*npnts
        lastx,lasty = x,y
        for index in range(self.HydraConfig["FIBERTUBE_NSEGMENTS"]):
            eps = self.HydraConfig["FIBERTUBE_SEGMENTS"][index+1]
            deflN = deflection*(0.5*eps*eps*eps-1.5*eps+1.)
            dN = originRadialDistance+pivotRadialDistance*eps
            rN = sqrt(dN*dN+deflN*deflN)
            phiN = atan2(deflN,dN)
            x0 = rN*cos(theta+phiN)
            y0 = rN*sin(theta+phiN)
            dx = x0-lastx
            dy = y0-lasty
            dnorm = sqrt(dx*dx+dy*dy)
            vx = -self.HydraConfig["FIBERTUBE_HALFDIAMETER"]*dy/dnorm
            vy = self.HydraConfig["FIBERTUBE_HALFDIAMETER"]*dx/dnorm

            tx[index] = lastx+vx
            ty[index] = lasty+vy
            vx *= -1
            vy *= -1
            tx[npnts-index-1] = lastx+vx
            ty[npnts-index-1] = lasty+vy
            lastx,lasty = x0,y0

        tx[npnts//2] = lastx+vx
        ty[npnts//2] = lasty+vy
        tx[npnts//2-1] = lastx-vx
        ty[npnts//2-1] = lasty-vy
        tube = shapely.Polygon([(x0,y0) for x0,y0 in zip(tx,ty)])
        geo = shapely.union(button,tube)
        shapely.prepare(geo)
        return geo

    def addCatalogObject(self,optID,objid,obj):
        #objid,obj = data
        self.idmap.append(objid)
        x = obj["x"]
        y = obj["y"]
        angle = atan2(y,x)
        if angle<0:
            angle += 2*pi
        #originDistance = sqrt(x*x+y*y)
        self.weights.append(obj["weight"])
        geometries = []
        button = shapely.Polygon([(bx+x,by+y) for bx,by in zip(self.buttonX,self.buttonY)])
        for fibIndex,fibid in enumerate(self.fibers):
            if obj["type"]=='F' or self.FiberDB[str(fibid)]["cable"]=='F':
                if obj["type"]!=self.FiberDB[str(fibid)]["cable"]:
                    geometries.append(None)
                    continue
            geo = self.getFiber(fibid,(x,y,angle,button))
            if geo is None:
                geometries.append(None)
                continue
            # Check that fiber won't hit parked fibers on either side
            """
            # This is commented out because parked fibers do not
            #   block fiber placements
            lo = (fibid-1)%self.HydraConfig["NFIBERS"]
            hi = (fibid+1)%self.HydraConfig["NFIBERS"]
            if (not self.FiberDB[str(lo)]["active"] and shapely.intersects(geo,self.parkedGeometries[lo])) or (not self.FiberDB[str(hi)]["active"] and shapely.intersects(geo,self.parkedGeometries[hi])):
                geometries.append(None)
                continue
            """
            self.objList[fibIndex].append(optID)
            geometries.append(geo)
        self.fiberGeometries.append(geometries)
        footprint = shapely.union_all(geometries)
        shapely.prepare(footprint)
        self.footprints.append(footprint)

    def prepPlacement(self):
        self.setButtons()

        self.fiberLists = []
        self.fiberGeometries = []
        self.footprints = []
        self.idmap = []
        self.weights = []

        self.fibers = []
        self.parkedGeometries = []
        self.FOPSindex = []
        for fibid,data in self.FiberDB.items():
            fibid = int(fibid)
            #self.parkedGeometries.append(self.getFiber(fibid))
            if data["active"]:
                if data["cable"]=="F":
                    self.FOPSindex.append(len(self.fibers))
                self.fibers.append(fibid)
        self.objList = [[] for _ in self.fibers]
        self.objListWeights = [[] for _ in self.fibers]

        for optID,(objid,obj) in enumerate(self.catalog.items()):
            self.addCatalogObject(optID,objid,obj)

    def setMatrix(self):
        """
        Wrapper routine for createMatrix(), which spawns in a worker thread
            Also displays a status bar.
        """
        worker = Worker(self.createMatrix)
        myWindow = ProgressWindow(self)
        myWindow.setTitle("Creating optimization\ncollision matrix")
        self.updateProgressSignal.connect(myWindow.updateProgress)
        self.threadPool.start(worker)
        myWindow.exec_()
        self.updateProgressSignal.disconnect(myWindow.updateProgress)

    def createMatrix(self):
        from multiprocessing import Pool,cpu_count
        self.prepPlacement()
        ncpu = cpu_count()
        if ncpu<=2:
            ncpu = 1
        else:
            ncpu -= 2
        if ncpu>8:
            ncpu = 8
        N = len(self.idmap)

        #set_start_method(PROCESS_START_METHOD)
        # 'spawn' can be slower and doesn't show progress easily
        if PROCESS_START_METHOD=="spawn":
            # spawn passes the data to each process instead of using the
            #  global MPH data structure
            indata = MPHelper()
            indata.catalog = self.catalog
            indata.idmap = self.idmap
            indata.fiberGeometries = self.fiberGeometries
            indata.footprints = self.footprints
            indata.HydraConfig = self.HydraConfig

            indices = []
            inp = []
            chunkSize = N//ncpu
            count = 0
            for i in range(ncpu):
                for j in range(chunkSize):
                    inp.append((j*ncpu+i,indata))
                    indices.append(j*ncpu+i)
                    count += 1
            while count<N:
                inp.append((count,indata))
                indices.append(count)
                count += 1
        else:
            # fork uses the global MPH data structure
            MPH.catalog = self.catalog
            MPH.idmap = self.idmap
            MPH.fiberGeometries = self.fiberGeometries
            MPH.footprints = self.footprints
            MPH.HydraConfig = self.HydraConfig

            inp = [_ for _ in range(N)]
            chunkSize = 1
        t = time.time()
        with Pool(ncpu) as pool:
            result = pool.map_async(populateMatrixEntries,inp,chunksize=chunkSize)
            PTOTAL = [10*i for i in range(10)]
            while True:
                if result.ready():
                    break
                P = int(100*(N-result._number_left)/N)
                if len(PTOTAL) and P>=PTOTAL[0]:
                    self.updateProgressSignal.emit(P)
                    del PTOTAL[0]
            MATRIX = result.get()

        if PROCESS_START_METHOD=="spawn":
            self.MATRIX = [[] for _ in range(N)]
            for index1,index2 in enumerate(indices):
                self.MATRIX[index2] = MATRIX[index1]
        else:
            self.MATRIX = MATRIX
        self.updateProgressSignal.emit(100)
        self.printMessageSignal.emit("Matrix created in {:.1f} seconds.".format(time.time()-t))


class ConfigLists:
    def __init__(self):
        self.IDs = []
        self.weights = []
        self.flags = []
        self.score = 0
        self.nitems = 0

    def addItem(self,newID,newWeight,newFlag):
        self.IDs.append(newID)
        self.weights.append(newWeight)
        self.flags.append(newFlag)
        self.score += newWeight
        self.nitems += 1

    def getID(self,index):
        if index>=0 and index<self.nitems:
            return self.IDs[index]

    def getFlag(self,index):
        if index>=0 and index<self.nitems:
            return self.flags[index]

    def getWeight(self,index):
        if index>=0 and index<self.nitems:
            return self.weights[index]

    def getIndex(self,ID):
        if ID in self.IDs:
            return self.IDs.index(ID)

    def update(self,index,newID,newWeight,newFlag):
        self.IDs[index] = newID
        self.weights[index] = newWeight
        self.flags[index] = newFlag
        self.score = sum(self.weights)

class Configuration:
    currentConfig = ConfigLists()

    def zeroCurrentConfig(self):
        self.currentConfig = ConfigLists()
        self.reset_btn.setEnabled(False)
        self.reset_btn.setText("No\nConfiguration")

    def resetCurrentConfig(self,removeManual=False):
        for index,flag in enumerate(self.currentConfig.flags):
            if removeManual or not flag:
                    self.currentConfig.update(index,None,0,False)
        self.reset_btn.setEnabled(False)
        self.reset_btn.setText("No\nConfiguration")

    def addToCurrentConfig(self,ID,weight,flag):
        self.currentConfig.addItem(ID,weight,flag)

    def getCurrentConfigID(self,index):
        return self.currentConfig.getID(index)

    def getCurrentConfigFlag(self,index):
        return self.currentConfig.getFlag(index)

    def getCurrentConfigWeight(self,index):
        return self.currentConfig.getWeight(index)

    def getCurrentConfigIndex(self,ID):
        return self.currentConfig.getIndex(ID)

    def updateCurrentConfig(self,index,newID,newWeight,newFlag):
        self.currentConfig.update(index,newID,newWeight,newFlag)

    def copyCurrentConfig(self):
        tmp = ConfigLists()
        tmp.IDs = [_ for _ in self.currentConfig.IDs]
        tmp.weights = [_ for _ in self.currentConfig.weights]
        tmp.flags = [_ for _ in self.currentConfig.flags]
        tmp.score = self.currentConfig.score
        return tmp

    def restoreCurrentConfig(self,config):
        self.currentConfig.IDs = [_ for _ in config.IDs]
        self.currentConfig.weights = [_ for _ in config.weights]
        self.currentConfig.flags = [_ for _ in config.flags]
        self.currentConfig.score = config.score

    def iterateCurrentConfig(self):
        for X in enumerate(self.currentConfig.IDs):
            yield X

    def iterateCurrentFlags(self):
        for X in enumerate(self.currentConfig.flags):
            yield X

    def updateBestConfig(self):
        if self.currentConfig.score>0:
            self.reset_btn.setEnabled(True)
            self.reset_btn.setText("Reset Score:\n%d"%(int(self.currentConfig.score)))
from PyQt6.QtWidgets import QGraphicsView,QGraphicsScene,QGraphicsEllipseItem, \
        QGraphicsLineItem,QGraphicsPolygonItem,QGraphicsRectItem,QMenu, \
        QGraphicsPathItem
from PyQt6.QtGui import QPainter,QColor,QBrush,QPen,QPolygonF,QPainterPath, \
        QTransform,QPainterPathStroker,QFont
from PyQt6.QtCore import Qt,QPointF,QPoint,QRectF


"""
Classes for display objects, e.g., fibers, compasses, and target markers.
"""

# This needs to be accounted for when determining the button size (for
#  collision detection).
BUTTON_PEN_WIDTH = 0.5

BlueBrush = QBrush(Qt.GlobalColor.blue) # For blue fibers
RedBrush = QBrush(Qt.GlobalColor.red) # For red fibers and illegal fibers
GrayBrush = QBrush(Qt.GlobalColor.lightGray) # For inactive fibers
YellowBrush = QBrush(Qt.GlobalColor.yellow) # For FOPS and stars
BlackBrush = QBrush(Qt.GlobalColor.black) # For active fibers and default pen
DarkRedBrush = QBrush(Qt.GlobalColor.darkRed) # For illegal fibers
ScienceBrush = QBrush(QColor(200,255,200)) # For science objects
SkyBrush = QBrush(QColor(200,200,255)) # For sky objects


Pen = QPen(BlackBrush,0.3) # Default pen
AssignedPen = QPen(RedBrush,0.3) # Border of assigned targets
PlacedPen = QPen(QBrush(Qt.GlobalColor.darkGreen),0.3) # Border of placed targets

BlackButtonPen = QPen(Qt.GlobalColor.black,BUTTON_PEN_WIDTH) # Active buttons
GrayButtonPen = QPen(Qt.GlobalColor.gray,BUTTON_PEN_WIDTH) # Inactive buttons

nullPen = QPen(Qt.GlobalColor.black,0.) # Zero-width pen


class Compass(QGraphicsPathItem):
    """
    Rotator compass and base class for FieldCompass
    """
    def __init__(self,x0,y0):
        super().__init__()
        self.x0 = x0
        self.y0 = y0

        self.width = 2
        self.headwidth = 4
        self.height = 216
        self.headheight = 10
        self.rotation = 0
        self.drawCompass()

    def drawCompass(self):
        """
        The compass is drawn as a filled line path, with points empirically
          chosen.
        """
        path = QPainterPath()
        path.moveTo(self.width/2,-self.width/2)
        path.lineTo(self.width/2,self.height+self.headheight)
        path.lineTo(-self.headwidth,self.height)
        path.lineTo(-self.width/2,self.height)
        path.lineTo(-self.width/2,self.width/2)
        path.lineTo(-self.height,self.width/2)
        path.lineTo(-self.height,self.headwidth)
        path.lineTo(-self.height-self.headheight,-self.width/2)
        path.lineTo(self.width/2,-self.width/2)

        path.translate(self.x0,self.y0)

        self.path = path
        self.setPath(self.path)
        self.setTransformOriginPoint(self.x0,self.y0)

    def rotate(self,rotation):
        self.rotation = -rotation
        self.setRotation(self.rotation)
        self.update()

class FieldCompass(Compass):
    """
    Compass with N,E annotations
    """
    def __init__(self,x0,y0):
        super().__init__(x0,y0)
        self.width = 4
        self.headwidth = 6
        self.headheight = 15
        height = 215
        self.drawCompass()

    def paint(self,*args):
        """
        We override the paint() method to append N,E labels
        """
        super().paint(*args)
        # Add N,E labels to the field compass
        args[0].save()
        transform = QTransform()
        transform.translate(self.x0,self.y0)
        transform.rotate(180)
        args[0].setTransform(transform,True)
        font = QFont("Helvetica",8)
        font.setBold(True)
        args[0].setFont(font)
        args[0].drawText(QPointF(self.width/2,-self.height-self.headheight/2),"N")
        args[0].drawText(QPointF(self.height+self.headheight/2,-self.width/2),"E")
        args[0].restore()


class Button(QGraphicsEllipseItem):
    """
    Fiber button; this is just the *button* (i.e., circle) part of the fiber.
      The button:
        - provides a context menu for assigning, parking, etc.
        - is the anchor for moving the fiber
    """
    def __init__(self,size,fiber):
        super(QGraphicsEllipseItem,self).__init__(-size/2,-size/2,size,size)
        self.fiber = fiber
        self.main = fiber.manager.main
        self.setToolTip("Fiber %s"%(fiber.fibid))
        self.startPos = None

    def contextMenuEvent(self,event):
        if self.fiber.active and not self.fiber.parked:
            fibid = self.fiber.fibid
            fibStr = '%d'%(fibid)

            menu = QMenu()
            menu.setTitle(fibStr)
            _ = menu.addSection("Fiber %s"%(fibStr))
            park = menu.addAction("Deassign")

            M = self.main
            park.triggered.connect(lambda: M.assignFiber(fib=fibid,remove=True))
            menu.exec(event.screenPos())

    def getNearestMarker(self):
        objs = self.collidingItems()
        if len(objs)==0:
            return None
        P = self.pos()
        match = None
        dist = 1e10
        for obj in objs:
            if type(obj) not in [SquareMarker,CircleMarker,StarMarker]:
                continue
            tmp = obj.pos()-P
            D = tmp.x()**2+tmp.y()**2
            if D<dist:
                match = obj
                dist = D
        return match

    def mousePressEvent(self,event):
        """
        #  We override to allow the button to be moved.
        #
        #  self.startPos not None signals that the button is being moved.
        """
        super().mousePressEvent(event)
        if self.startPos is None and self.fiber.active \
                and event.button()==Qt.MouseButton.LeftButton:
            self.startPos = self.pos()

    def mouseMoveEvent(self,event):
        super().mouseMoveEvent(event)
        if self.startPos is not None:
            P = self.pos()
            self.fiber.setFiber(P)

    def mouseReleaseEvent(self,event):
        super().mouseReleaseEvent(event)
        if self.startPos is None or event.button()!=Qt.MouseButton.LeftButton:
            return
        """
        # We check that the fiber has been moved to an OK location 
        """
        if self.fiber.psi<self.fiber.manager.MAXBEND and self.fiber.ext<self.fiber.manager.MAXEXTEND:
            match = self.getNearestMarker()
            if match is not None:
                if str(self.fiber.objid)!=match.objid:
                    self.fiber.setFiber(match.pos())
                    self.setPos(match.pos())
                    if self.fiber.psi<self.fiber.manager.MAXBEND and self.fiber.ext<self.fiber.manager.MAXEXTEND:
                        if self.main.assignFiber(fib=self.fiber.fibid,obj=match.objid):
                            self.startPos = None
                            return
        self.setPos(self.startPos)
        self.fiber.setFiber(self.startPos)
        self.startPos = None

class Fiber:
    """
    Fiber describes the full fiber object, including the button and the fiber
      tube. Note that the tube has two components, the visual component and
      a `shadow' component for collision detection consistent with the server.
    """
    def __init__(self,data,manager):
        from math import atan2
        self.manager = manager
        self.BUTTONSIZE = manager.BUTTONSIZE-BUTTON_PEN_WIDTH
        self.fibid = data["fiber"]
        self.x,self.y = manager.hydra2gui(data['x'],data['y'])
        self.xpivot,self.ypivot = manager.hydra2gui(data["xpivot"],data["ypivot"])
        self.pivotX = self.xpivot-self.manager.x0
        self.pivotY = self.ypivot-self.manager.y0
        self.pivotDistance = (self.pivotX**2+self.pivotY**2)**0.5
        self.theta = atan2(self.pivotY,self.pivotX)
        self.cable = data["cable"]
        self.status = data["status"]
        self.slit = data["slit"]
        self.active = data["active"]
        self.queued = data["queued"]
        self.parked = data["parked"]
        self.stowed = data["stowed"]
        self.objid = data["object"]
        self.legal = True

        # Valid FOPS are always active
        if self.cable=='F' and self.status=='A':
            self.active = True


        self.plotButton = Button(self.BUTTONSIZE,self)
        self.plotFiber = QGraphicsPathItem()
        if self.cable=='R':
            self.plotFiber.setPen(QPen(Qt.GlobalColor.red,0.))
        elif self.cable=='B':
            self.plotFiber.setPen(QPen(Qt.GlobalColor.blue,0.))
        elif self.cable=='F':
            self.plotFiber.setPen(QPen(Qt.GlobalColor.yellow,0.))
        else:
            self.plotFiber.setPen(QPen(Qt.GlobalColor.black,0.))

        self.plotFiber.setOpacity(0.7)
        self.collisionFiber = QGraphicsPolygonItem()
        self.collisionFiber.setPen(nullPen)
        self.collisionFiber.setOpacity(0.)
        self.collisionFiber.fibid = self.fibid
        self.setFiber()
        self.setObject(data["object"])

        self.manager.fiberScene.addItem(self.plotFiber)
        self.manager.fiberScene.addItem(self.plotButton)
        self.manager.fiberScene.addItem(self.collisionFiber)
        self.drawFiber()

    def setObject(self,objid):
        """
        Show the object ID if the fiber is assigned to an object.
        """
        self.objid = objid
        if objid>=0:
            self.plotFiber.setToolTip("Fiber %s\n(Object %d)"%(self.fibid,objid))
        else:
            self.plotFiber.setToolTip("Fiber %s"%(self.fibid))

    def setVisible(self,state):
        """
        Sync the visibility of the fibertube and button.
        """
        if state:
            self.plotFiber.show()
            self.plotButton.show()
        else:
            self.plotFiber.hide()
            self.plotButton.hide()

    def getFiberGeometry(self,pos=None):
        """
        We plot the fibers using the same parametric description as used by
        the fiber collision algorithm, that is:
            y = 0.5*x^3 - 1.5*x + 1
        We then scale the points to the actual fiber-position geometry,
        calculating the deflection from the radial line and the extent
        along that line. Finally, we rotate to the correct angle.
        """
        from math import atan2,sin,cos,pi
        # These are empirically determined to be the Bezier control points
        #   needed to mimic the polynomial on [0,1]: y = 0.5*x^3-1.5*x+1
        xcontrol = [0., 1./3, 2./3., 1.]
        ycontrol = [0., 0., 0.5,1.]

        if pos is None:
            xin = self.x
            yin = self.y
        else:
            xin = pos.x()
            yin = pos.y()

        theta = self.theta
        P = self.pivotDistance

        X = xin-self.manager.x0
        Y = yin-self.manager.y0
        D = (X**2+Y**2)**0.5

        phi = atan2(Y,X)-theta

        defl = D*sin(phi)
        Dproj = D*cos(phi)
        ext = P-Dproj
        self.psi = abs(atan2(defl,ext))*180/pi
        self.ext = ext

        x = [P-xc*ext for xc in xcontrol]
        y = [yc*defl for yc in ycontrol]
        xrot = [0.,0.,0.,0.]
        yrot = [0.,0.,0.,0.]
        s = sin(theta)
        c = cos(theta)

        for i in range(len(x)):
            xrot[i] = c*x[i]-s*y[i]+self.manager.x0
            yrot[i] = s*x[i]+c*y[i]+self.manager.y0

        eps = self.manager.FIBERSEGMENTS[1:]
        points = [0]*(len(eps)+1)*2
        lastx,lasty = X,Y#xin,yin
        for i in range(len(eps)):
            deflN = defl*(0.5*eps[i]**3-1.5*eps[i]+1)
            dN = Dproj+ext*eps[i]
            rn = (deflN**2+dN**2)**0.5
            phiN = atan2(deflN,dN)

            xp = rn*cos(theta+phiN)
            yp = rn*sin(theta+phiN)

            dx = xp-lastx
            dy = yp-lasty
            dnorm = (dx*dx+dy*dy)**0.5

            vx = -self.manager.FIBERHALFWIDTH*dy/dnorm
            vy = self.manager.FIBERHALFWIDTH*dx/dnorm
            points[i] = QPointF(lastx+vx,lasty+vy)
            
            vx *= -1
            vy *= -1
            points[9-i] = QPointF(lastx+vx,lasty+vy)
            lastx,lasty = xp,yp
        points[5] = QPointF(lastx+vx,lasty+vy)
        points[4] = QPointF(lastx-vx,lasty-vy)
        for i in range(len(points)):
            points[i].setX(points[i].x()+self.manager.x0)
            points[i].setY(points[i].y()+self.manager.y0)

        return xrot,yrot,vx,vy,points

    def setFiber(self,pos=None):
        X,Y,vx,vy,polyPoints = self.getFiberGeometry(pos)

        path = QPainterPath()
        X1 = [_+vx for _ in X]
        Y1 = [_+vy for _ in Y]
        path.moveTo(X1[0],Y1[0])
        path.cubicTo(X1[1],Y1[1],X1[2],Y1[2],X1[3],Y1[3])

        X2 = [_-vx for _ in X][::-1]
        Y2 = [_-vy for _ in Y][::-1]
        path.lineTo(X2[0],Y2[0])
        path.cubicTo(X2[1],Y2[1],X2[2],Y2[2],X2[3],Y2[3])

        self.plotFiber.setPath(path)
        M = self.collisionFiber
        self.collisionFiber.setPolygon(QPolygonF(polyPoints))

        if self.active:
            self.plotFiber.setBrush(BlackBrush)
        else:
            self.plotFiber.setBrush(GrayBrush)
        if self.queued:
            if self.cable=='R':
                self.plotFiber.setBrush(QColor("orange"))
            else:
                self.plotFiber.setBrush(Qt.GlobalColor.cyan)

        #
        # Simple tests of placing validity
        #
        self.legal = True
        if self.psi>self.manager.MAXBEND or self.ext>self.manager.MAXEXTEND:
            self.legal = False
            self.plotFiber.setBrush(RedBrush)

        #
        # Check that the fiber and button don't collide with other fibers or
        #  buttons (other than their own)
        lo = (self.fibid-1)%self.manager.NFIBERS
        hi = (self.fibid+1)%self.manager.NFIBERS
        if self.legal:
            for obj in self.collisionFiber.collidingItems():
                if obj==self.plotButton:
                    continue
                if not self.legalParkTest(obj,lo,hi):
                    self.legal = False
                    break
                #if type(obj) in [Button,QGraphicsPolygonItem]:
                #    self.legal = False
        if self.legal:
            for obj in self.plotButton.collidingItems():
                if obj==self.collisionFiber:
                    continue
                if not self.legalParkTest(obj,lo,hi):
                    self.legal = False
                    break
                #if type(obj) in [Button,QGraphicsPolygonItem]:
                #    self.legal = False
        if not self.legal:
            self.plotFiber.setBrush(DarkRedBrush)

        if self.cable=='F':
            pen = self.plotFiber.pen()

        # MWAW -- this is just for engineering tests of fiber positioning
        self.plotButton.setToolTip("Fiber %s\nBend: %4.2f"%(self.fibid,self.psi))

    def legalParkTest(self,obj,lo,hi):
        objType = type(obj)
        testFibers = self.manager.Fibers
        if objType==Button:
            if lo in testFibers and testFibers[lo].plotButton==obj:
                return testFibers[lo].parked
            if hi in testFibers and testFibers[hi].plotButton==obj:
                return testFibers[hi].parked
            return False
        elif objType==QGraphicsPolygonItem:
            if lo in testFibers and testFibers[lo].collisionFiber==obj:
                return testFibers[lo].parked
            if hi in testFibers and testFibers[hi].collisionFiber==obj:
                return testFibers[hi].parked
            return False
        return True


    def drawFiber(self):
        # Allow dragging of the fiber is it is active
        self.plotButton.setFlag(QGraphicsEllipseItem.GraphicsItemFlag.ItemIsMovable,self.active)
        pen = BlackButtonPen if self.active else GrayButtonPen
        self.plotButton.setPos(self.x,self.y)
        self.setFiber()
        if self.status!='A':
            self.plotButton.setBrush(GrayBrush)
        elif self.cable=='R':
            self.plotButton.setBrush(RedBrush)
        elif self.cable=='B':
            self.plotButton.setBrush(BlueBrush)
        elif self.cable=='F':
            self.plotButton.setBrush(YellowBrush)
        else:
            self.plotButton.setBrush(GrayBrush)
        self.plotButton.setPen(pen)

    def setActiveStatus(self,status):
        self.active = status
        self.drawFiber()

    def setQueueStatus(self,status):
        self.queued = status
        self.drawFiber()

    def updateXY(self,x,y):
        self.x,self.y = x,y
        self.drawFiber()

class StarMarker(QGraphicsPolygonItem):
    """
    Star marker for FOPS targets.
    """
    def __init__(self,size,objid):
        from math import cos,sin,tan,pi
        points = []
        # Ensure the size is the _bounding_ size and the star is centered
        scale = 1./4.9798  # Empirically
        offset = 0.0502    #  determined

        # Right point of upper triangle
        x0 = cos(54*pi/180)*scale
        y0 = sin(54*pi/180)*scale
        # Center point
        x1 = 0.
        y1 = y0+abs(x0)*tan(72*pi/180)
        # Left point
        x2 = -x0
        y2 = y0
        points = [[x0,y0],[x1,y1],[x2,y2]]
        # Rotate upper and left points by 1/5 of a circle and add to point list
        for i in range(4):
            angle = (i+1)*2*pi/5
            S,C = sin(angle),cos(angle)
            _x1 = x1*C-y1*S
            _y1 = x1*S+y1*C
            _x2 = x2*C-y2*S
            _y2 = x2*S+y2*C
            points += [[_x1,_y1],[_x2,_y2]]
        super(QGraphicsPolygonItem,self).__init__(QPolygonF([QPointF(_x*size,(offset-_y)*size) for _x,_y in points]))
        self.setBrush(YellowBrush)
        self.setPen(Pen)
        self.objid = objid

    def setAssigned(self,placed):
        if placed:
            self.setPen(PlacedPen)
        else:
            self.setPen(AssignedPen)

class SquareMarker(QGraphicsRectItem):
    """
    Square marker for science targets.
    """
    def __init__(self,size,objid):
        super(QGraphicsRectItem,self).__init__(-size/2,-size/2,size,size)
        self.setBrush(ScienceBrush)
        self.setPen(Pen)
        self.objid = objid
    def setAssigned(self,placed):
        if placed:
            self.setPen(PlacedPen)
        else:
            self.setPen(AssignedPen)

class CircleMarker(QGraphicsEllipseItem):
    """
    Circle marker for sky targets.
    """
    def __init__(self,size,objid):
        super(QGraphicsEllipseItem,self).__init__(-size/2,-size/2,size,size)
        self.setBrush(SkyBrush)
        self.setPen(Pen)
        self.objid = objid
    def setAssigned(self,placed):
        if placed:
            self.setPen(PlacedPen)
        else:
            self.setPen(AssignedPen)

from PyQt6.QtWidgets import QGraphicsView,QGraphicsScene,QGraphicsEllipseItem, \
        QGraphicsLineItem,QMenu,QGraphicsPathItem,QGraphicsItem,\
        QGraphicsPixmapItem
from PyQt6.QtGui import QPainter,QBrush,QPen,QColor,QPainterPath,QPixmap,QImage,QNativeGestureEvent
from PyQt6.QtCore import Qt,QTimer,QRectF

class FiberDisplayView(QGraphicsView):
    """
    We subclass QGraphicsView to allow panning/zooming. We also override the
      default panning behavior of always having a drag-hand cursor.
    """
    def __init__(self,parent):
        super(QGraphicsView,self).__init__(parent)
        self.setRenderHint(QPainter.RenderHint.Antialiasing)
        self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
        self.setMouseTracking(True)
        self.cursor = Qt.CursorShape.ArrowCursor
        self.currentScale = 1.
        self.currentOffset = None
        self.main = parent.parent()

    def viewportEvent(self,event):
        if type(event)==QNativeGestureEvent and event.gestureType()==Qt.NativeGestureType.ZoomNativeGesture:
            self.wheelEvent(event)
            return True
        return super().viewportEvent(event)

    # Zoom with the mouse wheel
    def wheelEvent(self,event):
        zoom = 1.2
        if type(event)==QNativeGestureEvent:
            if event.value()<0:
                zoom = 1/zoom
        elif event.angleDelta().y()<0:
            zoom = 1/zoom
        self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
        self.currentScale *= zoom
        self.scale(zoom,zoom)
        self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)

    # Use a normal arrow cursor instead of the drag hand
    def enterEvent(self,event):
        super().enterEvent(event)
        self.viewport().setCursor(self.cursor)
    def mouseReleaseEvent(self,event):
        super().mouseReleaseEvent(event)
        self.viewport().setCursor(self.cursor)


class FiberDisplayScene(QGraphicsScene):
    """
    We subclass QGraphicsScene to always show the mouse position in the
      coordinates label.
    """
    def __init__(self,width,height,manager):
        super(QGraphicsScene, self).__init__(0,0,width,height)
        self.manager = manager
        self.main = manager.main

    # Update the coordinates
    def mouseMoveEvent(self,event):
        super().mouseMoveEvent(event)
        x = event.scenePos().x()
        y = event.scenePos().y()
        x,y = self.manager.gui2hydra(x,y)
        self.main.xcoord_label.setText('x=%4d'%(x))
        self.main.ycoord_label.setText('y=%4d'%(y))

class FocalPlate(QGraphicsEllipseItem):
    """
    FocalPlate graphically represents the Hydra plate, but also shows the
      PS1 image of the field and implements a `move here/assign target'
      context menu.
    """
    def __init__(self,manager,x0,y0,dx,dy):
        super(QGraphicsEllipseItem,self).__init__(x0,y0,dx,dy)
        self.manager = manager
        self.showPS1 = False
        self.angle = 0.

    def showImage(self,flag):
        self.showPS1 = flag
        self.update()

    def setPS1Image(self,img):
        import base64
        self.pixmap = QPixmap()
        data = base64.b64decode(img)
        res = self.pixmap.loadFromData(data)
        self.manager.main.ps1image_cbox.setEnabled(res)
        self.update()

    def setPS1ImageDirect(self,img,angle):
        #self.pixmap = QPixmap()
        #img = img.convert("RGB")
        self.angle = angle
        data = img.tobytes("raw","RGB")
        qim = QImage(data,img.size[0],img.size[1],QImage.Format.Format_RGB888)
        self.pixmap = QPixmap.fromImage(qim)
        self.manager.main.ps1image_cbox.setEnabled(True)
        self.manager.main.ps1image_cbox.setChecked(True)
        self.update()

    def contextMenuEvent(self,event):
        menu = QMenu()
        addskyposition = menu.addAction("Add Sky Position")
        addskyposition.triggered.connect(lambda: self._addSkyPosition(event.scenePos()))
        menu.exec(event.screenPos())

    def _addSkyPosition(self,pos):
        """
        Get the RA/Dec at the plate position and ask to assign a sky position.
        """
        X = pos.x()
        Y = pos.y()
        xx,yy = self.manager.gui2hydra(X,Y)
        ra,dec = self.manager.main.plateToSky(xx,yy)
        self.manager.main.addTarget(ra,dec)

    def paint(self,*args):
        super().paint(*args)
        if self.showPS1:
            args[0].setClipPath(self.shape())
            args[0].rotate(self.angle)
            args[0].drawPixmap(self.rect(),self.pixmap,QRectF(self.pixmap.rect()))

class FiberDisplayManager:
    PLATESIZE = 420

    PlateBrush = QBrush(QColor(192,192,192))
    BlackBrush = QBrush(Qt.GlobalColor.black)

    def __init__(self,parent):#,fiberInitDB):
        self.main = parent
        self.initialized = False

        self.FiberDB = None
        self.TargetDB = None
        self.tracking = False

        self.HYDRAPLATE = parent.HydraConfig["PLATE"]
        self.SCALE = self.PLATESIZE/2/self.HYDRAPLATE
        self.FIBERHALFWIDTH = parent.HydraConfig["FIBERTUBE_HALFDIAMETER"]*self.SCALE
        self.BUTTONSIZE = parent.HydraConfig["FIBERBUTTON_RADIUS"]*2*self.SCALE
        self.FIBERSEGMENTS = parent.HydraConfig["FIBERTUBE_SEGMENTS"]
        from math import pi
        self.MAXBEND = parent.HydraConfig["MAXANGLE"]*180/pi
        self.MAXEXTEND = parent.HydraConfig["MAXEXTEND"]*self.SCALE
        self.NFIBERS = parent.HydraConfig["NFIBERS"]

    def init(self,fiberInitDB):
        self.initialized = True
        self.Fibers = {}
        self.Targets = []

        self.blinkingMarker = None

        size = self.main.fiberdisplay.size()
        width,height = size.width()-4,size.height()-4
        self.x0 = width/2
        self.y0 = height/2

        self.fiberScene = FiberDisplayScene(width,height,self)
        self.main.fiberdisplay.setScene(self.fiberScene)

        # First add the compasses
        self.compass = FieldCompass(self.x0,self.y0)
        self.compass.setBrush(QBrush(self.BlackBrush))
        self.compass.setPen(QPen(self.BlackBrush,0.3))
        self.compass.setVisible(False)
        self.fiberScene.addItem(self.compass)

        #self.rotator = Compass(self.x0,self.y0)
        #self.rotator.setBrush(QBrush(QColor(190,74,68)))
        #self.rotator.setPen(QPen(self.BlackBrush,0))
        #self.fiberScene.addItem(self.rotator)

        PS = self.PLATESIZE
        self.plate = FocalPlate(self,-PS/2,-PS/2,PS,PS)
        self.plate.setPos(self.x0,self.y0)

        self.plate.setBrush(self.PlateBrush)
        self.plate.setPen(QPen(self.BlackBrush,2))

        self.fiberScene.addItem(self.plate)

        # Now add the fibers
        self.createFibers(fiberInitDB)

        # Connect toggles to show/hide markers and PS1 image
        self.main.showtargets_cbox.stateChanged.connect(self.targetsShowHide)
        self.main.showfops_cbox.stateChanged.connect(self.fopsShowHide)
        self.main.showskys_cbox.stateChanged.connect(self.skysShowHide)
        self.main.ps1image_cbox.stateChanged.connect(self.imageShowHide)

        self.BlinkTimer = QTimer(self.main)
        self.BlinkTimer.timeout.connect(self.blinkMarker)


    def setImage(self,img):
        self.plate.setPS1Image(img)
    def setImageDirect(self,img,angle=0):
        self.plate.setPS1ImageDirect(img,angle)

    def createFibers(self,fiberDB):
        for fibid,data in fiberDB.items():
            fibid = int(fibid)
            self.Fibers[fibid] = Fiber(data,self)

    def hydra2gui(self,x,y):
        return x*self.SCALE+self.x0,-y*self.SCALE+self.y0

    def gui2hydra(self,x,y):
        return (x-self.x0)/self.SCALE,-(y-self.y0)/self.SCALE

    def startUnassignedBlink(self,state,nblinks=15):
        self.BlinkTimer.stop()
        if self.blinkingMarker is not None:
            for M in self.blinkingMarker:
                M.setScale(1.)
        self.blinkingMarker = []
        for fibid,fiber in self.Fibers.items():
            if fiber.active and fiber.objid<0:
                self.blinkingMarker.append(fiber.plotButton)
        if len(self.blinkingMarker)>0:
            self.nMarkerBlinks = nblinks
            for M in self.blinkingMarker:
                M.setScale(2)
            self.BlinkTimer.start(150)

    def startMarkerBlink(self,state=None,nblinks=15):
        self.BlinkTimer.stop()
        if self.blinkingMarker is not None:
            for M in self.blinkingMarker:
                M.setScale(1)
        self.blinkingMarker = None
        for M in self.Targets:
            if M.objid==state:
                self.blinkingMarker = [M]
                break
        if self.blinkingMarker is None:
            return
        self.nMarkerBlinks = nblinks
        self.main.fiberdisplay.centerOn(self.blinkingMarker[0].pos())
        self.blinkingMarker[0].setScale(2)
        self.BlinkTimer.start(150)

    def blinkMarker(self):
        self.nMarkerBlinks -= 1
        if self.nMarkerBlinks>0:
            for M in self.blinkingMarker:
                scale = M.scale()
                if scale==1:
                    M.setScale(2)
                else:
                    M.setScale(1)
        else:
            self.BlinkTimer.stop()
            for M in self.blinkingMarker:
                M.setScale(1)

    def updateRotator(self,angle):
        self.rotator.rotate(angle)

    def updateSymbols(self,doUpdate):
        if doUpdate:
            self.updateFibers()
            if self.TargetDB is not None:
                self.updateTargets()

    def updateFiberDB(self,db):
        if not self.initialized:
            self.init(db)
        else:
            self.FiberDB = db
        self.updateFibers()

    def updateTargetDB(self,db,angle=None):
        self.TargetDB = db
        if angle is not None:
            self.compass.setVisible(True)
            self.compass.rotate(angle)
        self.updateTargets()

    def updateFibers(self):
        if self.FiberDB is None:
            return
        for fibid,data in self.FiberDB.items():
            fibid = int(fibid)
            self.Fibers[fibid].stowed = data["stowed"]
            self.Fibers[fibid].parked = data["parked"]
            # NB: This could end up drawing the fibers multiple times
            if data["active"]!=self.Fibers[fibid].active:
                self.Fibers[fibid].setActiveStatus(data["active"])
            if data["queued"]!=self.Fibers[fibid].queued:
                self.Fibers[fibid].setQueueStatus(data["queued"])
            if data["object"]!=self.Fibers[fibid].objid:
                self.Fibers[fibid].setObject(data["object"])
            x,y = self.hydra2gui(data['x'],data['y'])
            if self.Fibers[fibid].x!=x or self.Fibers[fibid].y!=y:
                self.Fibers[fibid].updateXY(x,y)

    def updateTargets(self):
        # Remove the old targets
        while len(self.Targets):
            self.fiberScene.removeItem(self.Targets.pop())
        # Add the new targets
        if self.TargetDB is None:
            return
        for objid,data in self.TargetDB.items():
            x,y = self.hydra2gui(data['x'],data['y'])
            if data["type"]=='F':
                M = StarMarker(5,objid)
            elif data["type"]=='S':
                M = CircleMarker(3,objid)
            else:
                M = SquareMarker(3,objid)
            M.setPos(x,y)
            if self.plate.showPS1:
                M.setOpacity(0.5)
            if data["fibid"] is not None:
                M.setToolTip("Objid: %s (Fiber %s)\n%s"%(objid,data["fibid"],data["name"].strip()))
            else:
                M.setToolTip("Objid: %s\n%s"%(objid,data["name"].strip()))
            self.Targets.append(M)
            self.fiberScene.addItem(self.Targets[-1])
        self.targetsShowHide()
        self.fopsShowHide()
        self.skysShowHide()
        self.TargetDB = None

    def doAcquire(self): # OBSOLETE
        cboxes = [self.main.showtargets_cbox,
                  self.main.showfops_cbox,
                  self.main.showskys_cbox]
        if self.main.hideFibersAndObjects.isChecked():
            for cbox in cboxes:
                cbox.setEnabled(False)
            for target in self.Targets:
                target.hide()
            for fiber in self.Fibers:
                if self.Fibers[fiber].cable!='F':
                    self.Fibers[fiber].setVisible(False)
        else:
            for cbox in cboxes:
                cbox.setEnabled(True)
            for target in self.Targets:
                target.show()
            for fiber in self.Fibers:
                self.Fibers[fiber].setVisible(True)


    def targetsShowHide(self):
        self.markerShowHide(self.main.showtargets_cbox.isChecked(),SquareMarker)

    def fopsShowHide(self):
        self.markerShowHide(self.main.showfops_cbox.isChecked(),StarMarker)

    def skysShowHide(self):
        self.markerShowHide(self.main.showskys_cbox.isChecked(),CircleMarker)

    def markerShowHide(self,show,marker):
        for target in self.Targets:
            if type(target)==marker:
                if show:
                    target.show()
                else:
                    target.hide()

    def imageShowHide(self):
        state = self.main.ps1image_cbox.isChecked()
        self.plate.showImage(state)
        opacity = 0.5 if state else 1
        for target in self.Targets:
            target.setOpacity(opacity)
from math import pi,cos,sin
import requests

NFIBERS = 288


class FiberInitializer:

    def getConcentricities(self,filename=None):
        URL = "https://www.wiyn.org/hydraConcentricities.json"
        try:
            result = requests.get(URL)
        except:
            result = None
        if result:
            confile = result.text.read()
            ofile = open(self.cachedir+"/hydraConcentricities.json",'w')
            ofile.write(confile)
            ofile.close()
        else:
            if filename is not None:
                try:
                    confile = open(filename).read()
                except:
                    self.printError("Could not open the file "+filename+" for fiber information.")
                    filename = None
            if filename is None:
                filename = QFileDialog.getOpenFileName(self,"Select Concentricities File",self.cachedir)[0]
                if filename!="":
                    self.printMessage("Using file "+filename+" for fiber information")
                    try:
                        confile = open(filename).read()
                    except:
                        self.printError("Could not open file:",filename)
                        return
                else:
                    return
        self.processConcentricityFile(confile)

    def processConcentricityFile(self,concdata):
        try:
            concen = eval(concdata)
        except:
            return processConcentricityFileOldFormat(concdata)

        FiberDB = {}
        for fibID,fibData in concen.items():
            if fibID=="modified":
                continue
            fiber = int(fibID)
            slitid = fibData["slit"]
            cable = fibData["cable"]
            status = fibData["status"]

            angle = 2*pi*fiber/self.HydraConfig["NFIBERS"]
            cangle = cos(angle)
            sangle = sin(angle)

            parkX = self.HydraConfig["PARK"]*cangle
            parkY = self.HydraConfig["PARK"]*sangle
            pivotX = self.HydraConfig["PIVOT"]*cangle
            pivotY = self.HydraConfig["PIVOT"]*sangle

            if cable=="F": slitid = -1
            FiberDB[fibID] = {"fiber":fiber,
                              "x":parkX,
                              "y":parkY,
                              "theta":angle,
                              "cable":cable,
                              "status":status,
                              "slit":slitid,
                              "object":-1,
                              "xpark":parkX,
                              "ypark":parkY,
                              "xstow":parkX,
                              "ystow":parkY,
                              "xpivot":pivotX,
                              "ypivot":pivotY,
                              "active":cable=="F" and status=="A",
                              "queued":False,
                              "parked":True,
                              "stowed":False}
        self.FiberDB = FiberDB

    def processConcentricityFileOldFormat(self,confile):
        FiberDB = {}

        preamble = True
        for line in confile.split('\n'):
            if preamble:
                if line[:4]=="#FIB":
                    preamble = False
                continue
            if line.strip()=="":
                continue
            fibid,slitid,cable,status,concentricity,theta = line.split()
            fiber = int(fibid)
            
            angle = 2*pi*fiber/self.HydraConfig["NFIBERS"]
            cangle = cos(angle)
            sangle = sin(angle)

            parkX = self.HydraConfig["PARK"]*cangle
            parkY = self.HydraConfig["PARK"]*sangle
            pivotX = self.HydraConfig["PIVOT"]*cangle
            pivotY = self.HydraConfig["PIVOT"]*sangle

            if cable=="F": slitid = -1
            FiberDB[fibid] = {"fiber":int(fibid),
                              "x":parkX,
                              "y":parkY,
                              "theta":angle,
                              "cable":cable,
                              "status":status,
                              "slit":slitid,
                              "object":-1,
                              "xpark":parkX,
                              "ypark":parkY,
                              "xstow":parkX,
                              "ystow":parkY,
                              "xpivot":pivotX,
                              "ypivot":pivotY,
                              "active":cable=="F" and status=="A",
                              "queued":False,
                              "parked":True,
                              "stowed":False}
        self.FiberDB = FiberDB

# Form implementation generated from reading ui file 'FiberPlacement.ui'
#
# Created by: PyQt6 UI code generator 6.8.0
#
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again.  Do not edit this file unless you know what you are doing.


from PyQt6 import QtCore, QtGui, QtWidgets


class Ui_MainWindow(object):
    def setupUi(self, MainWindow):
        MainWindow.setObjectName("MainWindow")
        MainWindow.setEnabled(True)
        MainWindow.resize(1280, 676)
        sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Fixed)
        sizePolicy.setHorizontalStretch(0)
        sizePolicy.setVerticalStretch(0)
        sizePolicy.setHeightForWidth(MainWindow.sizePolicy().hasHeightForWidth())
        MainWindow.setSizePolicy(sizePolicy)
        MainWindow.setMinimumSize(QtCore.QSize(1280, 676))
        MainWindow.setMaximumSize(QtCore.QSize(1280, 676))
        MainWindow.setStyleSheet("")
        MainWindow.setUnifiedTitleAndToolBarOnMac(False)
        self.centralwidget = QtWidgets.QWidget(parent=MainWindow)
        self.centralwidget.setObjectName("centralwidget")
        self.coords = QtWidgets.QWidget(parent=self.centralwidget)
        self.coords.setGeometry(QtCore.QRect(582, 626, 51, 44))
        self.coords.setObjectName("coords")
        self.layoutWidget_5 = QtWidgets.QWidget(parent=self.coords)
        self.layoutWidget_5.setGeometry(QtCore.QRect(0, 0, 51, 44))
        self.layoutWidget_5.setObjectName("layoutWidget_5")
        self.verticalLayout = QtWidgets.QVBoxLayout(self.layoutWidget_5)
        self.verticalLayout.setSizeConstraint(QtWidgets.QLayout.SizeConstraint.SetMinimumSize)
        self.verticalLayout.setContentsMargins(0, 0, 0, 0)
        self.verticalLayout.setSpacing(0)
        self.verticalLayout.setObjectName("verticalLayout")
        self.xcoord_label = QtWidgets.QLabel(parent=self.layoutWidget_5)
        self.xcoord_label.setObjectName("xcoord_label")
        self.verticalLayout.addWidget(self.xcoord_label)
        self.ycoord_label = QtWidgets.QLabel(parent=self.layoutWidget_5)
        self.ycoord_label.setScaledContents(False)
        self.ycoord_label.setObjectName("ycoord_label")
        self.verticalLayout.addWidget(self.ycoord_label)
        self.fieldinfo = QtWidgets.QWidget(parent=self.centralwidget)
        self.fieldinfo.setGeometry(QtCore.QRect(11, 61, 121, 63))
        self.fieldinfo.setObjectName("fieldinfo")
        self.fieldname_label = QtWidgets.QLabel(parent=self.fieldinfo)
        self.fieldname_label.setGeometry(QtCore.QRect(0, 0, 121, 63))
        self.fieldname_label.setIndent(2)
        self.fieldname_label.setObjectName("fieldname_label")
        self.showmarkers = QtWidgets.QWidget(parent=self.centralwidget)
        self.showmarkers.setEnabled(True)
        self.showmarkers.setGeometry(QtCore.QRect(11, 589, 81, 81))
        self.showmarkers.setObjectName("showmarkers")
        self.layoutWidget_6 = QtWidgets.QWidget(parent=self.showmarkers)
        self.layoutWidget_6.setGeometry(QtCore.QRect(0, 0, 81, 98))
        self.layoutWidget_6.setObjectName("layoutWidget_6")
        self.markersLayout = QtWidgets.QVBoxLayout(self.layoutWidget_6)
        self.markersLayout.setSizeConstraint(QtWidgets.QLayout.SizeConstraint.SetMinAndMaxSize)
        self.markersLayout.setContentsMargins(0, 0, 0, 0)
        self.markersLayout.setSpacing(0)
        self.markersLayout.setObjectName("markersLayout")
        self.ps1image_cbox = QtWidgets.QCheckBox(parent=self.layoutWidget_6)
        self.ps1image_cbox.setObjectName("ps1image_cbox")
        self.markersLayout.addWidget(self.ps1image_cbox)
        self.showtargets_cbox = QtWidgets.QCheckBox(parent=self.layoutWidget_6)
        self.showtargets_cbox.setChecked(True)
        self.showtargets_cbox.setObjectName("showtargets_cbox")
        self.markersLayout.addWidget(self.showtargets_cbox)
        self.showfops_cbox = QtWidgets.QCheckBox(parent=self.layoutWidget_6)
        self.showfops_cbox.setChecked(True)
        self.showfops_cbox.setObjectName("showfops_cbox")
        self.markersLayout.addWidget(self.showfops_cbox)
        self.showskys_cbox = QtWidgets.QCheckBox(parent=self.layoutWidget_6)
        self.showskys_cbox.setChecked(True)
        self.showskys_cbox.setObjectName("showskys_cbox")
        self.markersLayout.addWidget(self.showskys_cbox)
        self.fiberdisplay = FiberDisplayView(parent=self.centralwidget)
        self.fiberdisplay.setGeometry(QtCore.QRect(10, 60, 625, 611))
        self.fiberdisplay.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        self.fiberdisplay.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        self.fiberdisplay.setObjectName("fiberdisplay")
        self.FiberTable = QtWidgets.QTableWidget(parent=self.centralwidget)
        self.FiberTable.setGeometry(QtCore.QRect(645, 5, 625, 571))
        self.FiberTable.setRowCount(99)
        self.FiberTable.setObjectName("FiberTable")
        self.FiberTable.setColumnCount(7)
        item = QtWidgets.QTableWidgetItem()
        font = QtGui.QFont()
        font.setPointSize(10)
        item.setFont(font)
        self.FiberTable.setHorizontalHeaderItem(0, item)
        item = QtWidgets.QTableWidgetItem()
        font = QtGui.QFont()
        font.setPointSize(10)
        item.setFont(font)
        self.FiberTable.setHorizontalHeaderItem(1, item)
        item = QtWidgets.QTableWidgetItem()
        font = QtGui.QFont()
        font.setPointSize(10)
        item.setFont(font)
        self.FiberTable.setHorizontalHeaderItem(2, item)
        item = QtWidgets.QTableWidgetItem()
        font = QtGui.QFont()
        font.setPointSize(10)
        item.setFont(font)
        self.FiberTable.setHorizontalHeaderItem(3, item)
        item = QtWidgets.QTableWidgetItem()
        font = QtGui.QFont()
        font.setPointSize(10)
        item.setFont(font)
        self.FiberTable.setHorizontalHeaderItem(4, item)
        item = QtWidgets.QTableWidgetItem()
        font = QtGui.QFont()
        font.setPointSize(10)
        item.setFont(font)
        self.FiberTable.setHorizontalHeaderItem(5, item)
        item = QtWidgets.QTableWidgetItem()
        font = QtGui.QFont()
        font.setPointSize(10)
        item.setFont(font)
        self.FiberTable.setHorizontalHeaderItem(6, item)
        self.FiberTable.horizontalHeader().setMinimumSectionSize(8)
        self.loadField_btn = QtWidgets.QPushButton(parent=self.centralwidget)
        self.loadField_btn.setGeometry(QtCore.QRect(10, 5, 101, 46))
        self.loadField_btn.setObjectName("loadField_btn")
        self.saveConfig_btn = QtWidgets.QPushButton(parent=self.centralwidget)
        self.saveConfig_btn.setGeometry(QtCore.QRect(110, 5, 101, 46))
        self.saveConfig_btn.setObjectName("saveConfig_btn")
        self.optimize_btn = QtWidgets.QPushButton(parent=self.centralwidget)
        self.optimize_btn.setGeometry(QtCore.QRect(220, 5, 101, 46))
        self.optimize_btn.setObjectName("optimize_btn")
        self.reset_btn = QtWidgets.QPushButton(parent=self.centralwidget)
        self.reset_btn.setGeometry(QtCore.QRect(320, 5, 101, 46))
        self.reset_btn.setObjectName("reset_btn")
        self.fiberCountTable = QtWidgets.QTableWidget(parent=self.centralwidget)
        self.fiberCountTable.setGeometry(QtCore.QRect(554, 61, 82, 90))
        self.fiberCountTable.setFrameShape(QtWidgets.QFrame.Shape.NoFrame)
        self.fiberCountTable.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        self.fiberCountTable.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        self.fiberCountTable.setAutoScroll(False)
        self.fiberCountTable.setEditTriggers(QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers)
        self.fiberCountTable.setTabKeyNavigation(False)
        self.fiberCountTable.setProperty("showDropIndicator", False)
        self.fiberCountTable.setDragDropOverwriteMode(False)
        self.fiberCountTable.setShowGrid(False)
        self.fiberCountTable.setGridStyle(QtCore.Qt.PenStyle.NoPen)
        self.fiberCountTable.setWordWrap(False)
        self.fiberCountTable.setCornerButtonEnabled(False)
        self.fiberCountTable.setObjectName("fiberCountTable")
        self.fiberCountTable.setColumnCount(2)
        self.fiberCountTable.setRowCount(4)
        item = QtWidgets.QTableWidgetItem()
        self.fiberCountTable.setVerticalHeaderItem(0, item)
        item = QtWidgets.QTableWidgetItem()
        self.fiberCountTable.setVerticalHeaderItem(1, item)
        item = QtWidgets.QTableWidgetItem()
        self.fiberCountTable.setVerticalHeaderItem(2, item)
        item = QtWidgets.QTableWidgetItem()
        self.fiberCountTable.setVerticalHeaderItem(3, item)
        item = QtWidgets.QTableWidgetItem()
        self.fiberCountTable.setHorizontalHeaderItem(0, item)
        item = QtWidgets.QTableWidgetItem()
        self.fiberCountTable.setHorizontalHeaderItem(1, item)
        item = QtWidgets.QTableWidgetItem()
        item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
        item.setFlags(QtCore.Qt.ItemFlag.ItemIsEnabled)
        self.fiberCountTable.setItem(0, 0, item)
        item = QtWidgets.QTableWidgetItem()
        item.setFlags(QtCore.Qt.ItemFlag.ItemIsEnabled)
        self.fiberCountTable.setItem(1, 0, item)
        item = QtWidgets.QTableWidgetItem()
        item.setFlags(QtCore.Qt.ItemFlag.ItemIsEnabled)
        self.fiberCountTable.setItem(1, 1, item)
        item = QtWidgets.QTableWidgetItem()
        item.setFlags(QtCore.Qt.ItemFlag.ItemIsEnabled)
        self.fiberCountTable.setItem(2, 0, item)
        item = QtWidgets.QTableWidgetItem()
        item.setFlags(QtCore.Qt.ItemFlag.NoItemFlags)
        self.fiberCountTable.setItem(2, 1, item)
        item = QtWidgets.QTableWidgetItem()
        item.setFlags(QtCore.Qt.ItemFlag.ItemIsEnabled)
        self.fiberCountTable.setItem(3, 0, item)
        item = QtWidgets.QTableWidgetItem()
        item.setFlags(QtCore.Qt.ItemFlag.ItemIsEnabled)
        self.fiberCountTable.setItem(3, 1, item)
        self.fiberCountTable.horizontalHeader().setVisible(False)
        self.fiberCountTable.horizontalHeader().setDefaultSectionSize(40)
        self.fiberCountTable.horizontalHeader().setHighlightSections(False)
        self.fiberCountTable.horizontalHeader().setMinimumSectionSize(10)
        self.fiberCountTable.verticalHeader().setVisible(False)
        self.fiberCountTable.verticalHeader().setDefaultSectionSize(20)
        self.fiberCountTable.verticalHeader().setHighlightSections(False)
        self.fiberCountTable.verticalHeader().setMinimumSectionSize(20)
        self.makeSkies_btn = QtWidgets.QPushButton(parent=self.centralwidget)
        self.makeSkies_btn.setGeometry(QtCore.QRect(430, 5, 101, 46))
        self.makeSkies_btn.setObjectName("makeSkies_btn")
        self.showUnassigned_btn = QtWidgets.QPushButton(parent=self.centralwidget)
        self.showUnassigned_btn.setGeometry(QtCore.QRect(530, 5, 101, 46))
        self.showUnassigned_btn.setObjectName("showUnassigned_btn")
        self.messageBox = QtWidgets.QTextEdit(parent=self.centralwidget)
        self.messageBox.setGeometry(QtCore.QRect(645, 579, 625, 91))
        self.messageBox.setReadOnly(True)
        self.messageBox.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextSelectableByMouse)
        self.messageBox.setObjectName("messageBox")
        self.fiberdisplay.raise_()
        self.fieldinfo.raise_()
        self.FiberTable.raise_()
        self.loadField_btn.raise_()
        self.saveConfig_btn.raise_()
        self.optimize_btn.raise_()
        self.reset_btn.raise_()
        self.showmarkers.raise_()
        self.coords.raise_()
        self.fiberCountTable.raise_()
        self.makeSkies_btn.raise_()
        self.showUnassigned_btn.raise_()
        self.messageBox.raise_()
        MainWindow.setCentralWidget(self.centralwidget)

        self.retranslateUi(MainWindow)
        QtCore.QMetaObject.connectSlotsByName(MainWindow)

    def retranslateUi(self, MainWindow):
        _translate = QtCore.QCoreApplication.translate
        MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow"))
        self.xcoord_label.setText(_translate("MainWindow", "x=-288"))
        self.ycoord_label.setText(_translate("MainWindow", "y=-288"))
        self.fieldname_label.setText(_translate("MainWindow", "Field Name"))
        self.ps1image_cbox.setText(_translate("MainWindow", "Image"))
        self.showtargets_cbox.setText(_translate("MainWindow", "Targets"))
        self.showfops_cbox.setText(_translate("MainWindow", "FOPS"))
        self.showskys_cbox.setText(_translate("MainWindow", "Skys"))
        item = self.FiberTable.horizontalHeaderItem(0)
        item.setText(_translate("MainWindow", "ID"))
        item = self.FiberTable.horizontalHeaderItem(1)
        item.setText(_translate("MainWindow", "Name"))
        item = self.FiberTable.horizontalHeaderItem(2)
        item.setText(_translate("MainWindow", "Mag"))
        item = self.FiberTable.horizontalHeaderItem(3)
        item.setText(_translate("MainWindow", "RA"))
        item = self.FiberTable.horizontalHeaderItem(4)
        item.setText(_translate("MainWindow", "Dec"))
        item = self.FiberTable.horizontalHeaderItem(5)
        item.setText(_translate("MainWindow", "Fib"))
        item = self.FiberTable.horizontalHeaderItem(6)
        item.setText(_translate("MainWindow", "Slit"))
        self.loadField_btn.setText(_translate("MainWindow", "Load Field"))
        self.saveConfig_btn.setText(_translate("MainWindow", "Save\n"
"Configuration"))
        self.optimize_btn.setText(_translate("MainWindow", "Optimize"))
        self.reset_btn.setText(_translate("MainWindow", "No"))
        item = self.fiberCountTable.verticalHeaderItem(0)
        item.setText(_translate("MainWindow", "Fibers"))
        item = self.fiberCountTable.verticalHeaderItem(1)
        item.setText(_translate("MainWindow", "Objs"))
        item = self.fiberCountTable.verticalHeaderItem(2)
        item.setText(_translate("MainWindow", "Skys"))
        item = self.fiberCountTable.verticalHeaderItem(3)
        item.setText(_translate("MainWindow", "FOPs"))
        item = self.fiberCountTable.horizontalHeaderItem(0)
        item.setText(_translate("MainWindow", "New Column"))
        item = self.fiberCountTable.horizontalHeaderItem(1)
        item.setText(_translate("MainWindow", "New Column"))
        __sortingEnabled = self.fiberCountTable.isSortingEnabled()
        self.fiberCountTable.setSortingEnabled(False)
        item = self.fiberCountTable.item(0, 0)
        item.setText(_translate("MainWindow", "Fibers"))
        item = self.fiberCountTable.item(1, 0)
        item.setText(_translate("MainWindow", "objs"))
        item = self.fiberCountTable.item(1, 1)
        item.setText(_translate("MainWindow", "999"))
        item = self.fiberCountTable.item(2, 0)
        item.setText(_translate("MainWindow", "skys"))
        item = self.fiberCountTable.item(2, 1)
        item.setText(_translate("MainWindow", "0"))
        item = self.fiberCountTable.item(3, 0)
        item.setText(_translate("MainWindow", "FOPs"))
        item = self.fiberCountTable.item(3, 1)
        item.setText(_translate("MainWindow", "0"))
        self.fiberCountTable.setSortingEnabled(__sortingEnabled)
        self.makeSkies_btn.setText(_translate("MainWindow", "Make Room\n"
"For Skies"))
        self.showUnassigned_btn.setText(_translate("MainWindow", "Show\n"
"Unassigned"))
from PyQt6.QtWidgets import QFileDialog,QHeaderView
from PyQt6 import QtCore
from PyQt6.QtCore import pyqtSlot,pyqtSignal,QTimer
from pathlib import Path
from math import pi,cos
import os,pickle,datetime,time
import shapely
from astropy.wcs import WCS
from astroquery.gaia import Gaia
from astropy.time import Time


HOME = str(Path.home())

def str2deg(instr):
    instr = instr.replace(":"," ")
    d,m,s = instr.split()
    sign = -1 if d[0]=='-' else 1
    return sign*(abs(float(d))+float(m)/60+float(s)/3600)

def ra2str(ra):
    H = ra/15.
    h = int(H)
    m = int((H-h)*60)
    s = ((H-h)*60-m)*60
    return "%02d %02d %06.3f"%(h,m,s)

def dec2str(dec):
    sign = "+" if dec>=0 else "-"
    dec = abs(dec)
    d = int(dec)
    m = int((dec-d)*60)
    s = ((dec-d)*60-m)*60
    return "%s%02d %02d %05.2f"%(sign,d,m,s)

class CatalogManager:
    # Required keywords
    headerKeywords = ["FIELDNAME","RA","DEC","LST","EXPTIME","WAVELENGTH","CABLE","OBSDATE"]
    # These keywords are optional
    headerKeywords += ["PA","GUIDEWAVELENGTH","MINFOPS","FOPSWEIGHT","BP-RP_MIN","BP-RP_MAX","GAIA_RANGE"]

    fiberSignal = pyqtSignal(dict)
    targetSignal = pyqtSignal(dict)

    def setupTable(self):
        colHead = self.FiberTable.horizontalHeader()
        for i in range(5):
            colHead.setSectionResizeMode(i,QHeaderView.ResizeMode.ResizeToContents)
        # Manually set the fiber/slit column widths (to be smaller than the default)
        for i in range(5,7):
            colHead.setSectionResizeMode(i,QHeaderView.ResizeMode.Fixed)
            colHead.resizeSection(i,35)
        self.FiberTable.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed)
        self.FiberTable.setSortingEnabled(True)
        self.FiberTable.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu)
        self.FiberTable.customContextMenuRequested.connect(self.fiberTableAction)

    def loadFieldFile(self,_=None,filename=None):
        if filename is None:
            filename = QFileDialog.getOpenFileName(self,"Select Target List",HOME)[0]
        if filename!="":
            # We put this behind a QTimer to give the OpenFile dialog a
            #  chance to close
            QTimer.singleShot(150,lambda: self.processTargetFile(filename))

    def processHeader(self,header):
        def reportMissing(key):
            self.printError("Missing required keyword:",key)
            return False
        OK = True
        badHeaders = []
        if header["FIELDNAME"]:
            self.FIELDNAME = header["FIELDNAME"]
        else:
            OK = reportMissing("FIELDNAME")
        if header["RA"]:
            try:
                h,m,s = [float(_) for _ in header["RA"].replace(':',' ').split()]
                if h<0 or h>23 or m<0 or m>59 or s<0 or s>=60:
                    raise
                self.FIELDRA = str2deg(header["RA"])*15#(h+m/60.+s/3600.)*15
                self.RA = header["RA"]
            except:
                badHeaders.append("RA")
        else:
            OK = reportMissing("RA")
        if header["DEC"]:
            try:
                d,m,s = [float(_) for _ in header["DEC"].replace(':',' ').split()]
                dec = str2deg(header["DEC"])
                if abs(dec)>90 or m<0 or m>59 or s<0 or s>=60:
                    raise
                self.FIELDDEC = dec
                self.DEC = header["DEC"]
            except:
                badHeaders.append("DEC")
        else:
            OK = reportMissing("DEC")
        if header["PA"]:
            try:
                self.PA = float(header["PA"])
            except:
                badHeaders.append("PA")
        else:
            try:
                ZD = self.sitePars["KPNO_LAT"]-self.FIELDDEC
                self.PA = 90-ZD if ZD>0 else ZD-90
                header["PA"] = str(self.PA)
                self.printMessage("Setting PA to {:.2f}.".format(self.PA))
            except:
                self.printError("Missing keyword PA and could not set default PA from DEC")
                OK = False
        if header["LST"]:
            try:
                h,m = [float(_) for _ in header["LST"].split(':')]
                self.LST = h+m/60.
            except:
                badHeaders.append("LST")
        else:
            OK = reportMissing("LST")
        if header["EXPTIME"]:
            try:
                self.EXPTIME = float(header["EXPTIME"])/3600
            except:
                badHeaders.append("EXPTIME")
        else:
            OK = reportMissing("EXPTIME")
        if header["WAVELENGTH"]:
            try:
                self.WAVELENGTH = float(header["WAVELENGTH"])
            except:
                badHeaders.append("WAVELENGTH")
        else:
            OK = reportMissing("WAVELENGTH")
        if header["CABLE"]:
            try:
                cable = header["CABLE"].upper()
                if cable not in ["RED","BLUE"]:
                    raise
                self.CABLE = cable
            except:
                badHeaders.append("CABLE")
        else:
            OK = reportMissing("CABLE")
        if header["OBSDATE"]:
            try:
                D = header["OBSDATE"].replace("/","-").split("-")
                if len(D)==3:
                    y,m,d = [int(_) for _ in D]
                else:
                    y,m = [int(_) for _ in D]
                    d = 15
                if y<100:
                    y += 2000
                self.DATE = datetime.datetime(y,m,d)
            except:
                badHeaders.append("OBSDATE")
        else:
            OK = reportMissing("OBSDATE")
        if header["GUIDEWAVELENGTH"]:
            try:
                self.GUIDEWAVELENGTH = float(header["GUIDEWAVELENGTH"])
            except:
                badHeaders.append("GUIDEWAVELENGTH")
        else:
            self.printMessage("Setting GUIDEWAVELENGTH to 5000")
            header["GUIDEWAVELENGTH"] = str(5000)
            self.GUIDEWAVELENGTH = 5000
        if header["MINFOPS"]:
            try:
                self.MINFOPS = int(header["MINFOPS"])
            except:
                badHeaders.append("MINFOPS")
        else:
            self.printMessage("Setting MINFOPS to 3")
            self.MINFOPS = 3
            header["MINFOPS"] = str(3)
        if header["FOPSWEIGHT"]:
            try:
                self.FOPSWEIGHT = int(header["FOPSWEIGHT"])
            except:
                badHeaders.append("FOPSWEIGHT")
        else:
            self.printMessage("Setting FOPSWEIGHT to 1000")
            self.FOPSWEIGHT = 1000
            header["FOPSWEIGHT"] = str(1000)
        if header["BP-RP_MIN"]:
            try:
                self.BPRP_MIN = float(header["BP-RP_MIN"])
            except:
                badHeaders.append("BP-RP_MIN")
        else:
            self.BPRP_MIN = None
        if header["BP-RP_MAX"]:
            try:
                self.BPRP_MAX = float(header["BP-RP_MAX"])
            except:
                badHeaders.append("BP-RP_MAX")
        else:
            self.BPRP_MAX = None
        if header["GAIA_RANGE"]:
            try:
                lo,hi = header["GAIA_RANGE"].split(',')
                lo = float(lo)
                hi = float(hi)
                assert lo<hi
                self.GAIA_RANGE = [lo,hi]
            except:
                badHeaders.append("GAIA_RANGE")
        else:
            self.GAIA_RANGE = None
        for key in badHeaders:
            self.printError("Could not parse header {}: {}".format(key,header[key]))
            OK = False
        if not OK:
            return

        # Now apply header info where necessary
        self.setABCoefficients()
        self.REFRA,self.REFDEC = self.refractCoords(self.FIELDRA*pi/180,self.FIELDDEC*pi/180)
        self.WCS = WCS({"CRVAL1":self.REFRA*180/pi,"CRVAL2":self.REFDEC*180/pi,
                        "CD1_1":1,"CD1_2":0,"CD2_1":0,"CD2_2":1.,
                        "CTYPE1":"RA---TAN","CTYPE2":"DEC--TAN"})
        FiberDB = {}
        # Reset fiber data and make the CABLE fibers active
        for fibid,data in self.FiberDB.items():
            data["object"] = -1
            data["x"] = data["xpark"]
            data["y"] = data["ypark"]
            data["parked"] = True
            data["queued"] = False
            if data["cable"]=='F':
                FiberDB[fibid] = data
                continue
            data["active"] = data["status"]=="A" and data["cable"]==self.CABLE[0]
            FiberDB[fibid] = data
        self.fiberSignal.emit(FiberDB)
        self.FiberDB = FiberDB
        return header


    def processTargetFile(self,filename):
        try:
            lines = open(filename).readlines()
        except:
            self.printError("Could not open target list!")
            return

        previousAssignments = None
        header = {}
        for key in self.headerKeywords:
            header[key] = None

        catalog = {}
        # Loop through the catalog line by line; we first collect header
        #  keywords, then set a flag when they've all been found.
        headerOK = False
        for line in lines:
            line = line.rstrip()
            if line[0]=="#":
                continue
            if not headerOK:
                tmp = line.split(":")
                if tmp[0] in self.headerKeywords:
                    value = line[line.find(":")+1:].strip()
                    if value=="None":
                        value = None
                    header[tmp[0]] = value
                    continue
                elif len(tmp)>1:
                    if tmp[0]=="SCORE":
                        previousAssignments = {}
                    else:
                        self.printMessage("Unknown keyword:",tmp[0])
                    continue
                # If we've made it this far then the line is not a comment
                #  or a header keyword. In that case we should have collected
                #  all headers, so we verify that.
                headerOK = True
                header = self.processHeader(header)
                if header is None:
                    self.printError("Invalid header, exiting")
                    return
            try:
                objid = int(line[:4])
                name = line[5:35].strip()
                mag = "%5.2f"%(float(line[36:41]))
                raStr = line[42:54]
                decStr = line[55:67]
                if decStr[0] not in ['+','-']:
                    decStr = "+"+decStr[1:]
                ra = str2deg(raStr)*15
                dec = str2deg(decStr)
                objType = "O"
                weight = int(line[68:73])
                fibid = None
                slitid = None
                if previousAssignments is not None:
                    objType = line[74]
            except:
                self.printError("Could not parse the line: ",line)
                continue
            xc,yc,xs,ys = self.skyToPlate(ra,dec)
            if objType=="F":
                x,y = xc,yc
            else:
                x,y = xs,ys
            catalog[objid] = {"name":name,
                              "mag":mag,
                              "RADeg":ra,
                              "DecDeg":dec,
                              "type":objType,
                              "weight":weight,
                              "ra":raStr,
                              "dec":decStr,
                              "fibid":fibid,
                              "slitid":slitid,
                              "x":x,
                              "y":y}
            if previousAssignments is not None:
                try:
                    fibid = line[76:79].strip()
                    if self.FiberDB[fibid]["active"]:
                        flag = len(line)>79 and line[79]=='*'
                        previousAssignments[objid] = [fibid,flag]
                    else:
                        self.printError("Could not assign fiber {} to object {} because the fiber is not active.".format(fibid,name))
                except:
                    pass
        if len(catalog)==0:
            self.printError("No valid objects provided.")
            return
        catalog = self.addGaiaFOPs(header,catalog)
        self.previousAssignments = previousAssignments
        self.applyCatalog(header,catalog)

    def addGaiaFOPs(self,header,catalog):
        """
        Query the Gaia DR3 source catalog for all stars with magnitudes
          10 < G < 12, increasing the range by 0.25mag in the event that
          there are not enough stars (eg., 10.25 < G < 12.25).

          Stars must have valid magnitudes and proper motions, and
          corrections for the latter are applied using the OBSDATE keyword
          and the Gaia epoch of 2016.0.
        """
        epoch = Time(self.DATE,format="datetime").decimalyear
        years = epoch-2016.0

        t = time.time()
        query = "SELECT source_id,ra,dec,pmra,pmdec,phot_g_mean_mag from gaiadr3.gaia_source WHERE DISTANCE(%f,%f,ra,dec)<0.5 and pmra is not null and phot_g_mean_mag<14 and phot_g_mean_mag is not null"%(self.FIELDRA,self.FIELDDEC)
        if self.BPRP_MAX is not None and self.BPRP_MIN is not None:
            query += " and ((phot_bp_mean_mag-phot_rp_mean_mag) between {} and {})".format(self.BPRP_MIN,self.BPRP_MAX)
        elif self.BPRP_MAX is not None:
            query += " and (phot_bp_mean_mag-phot_rp_mean_mag)<{}".format(self.BPRP_MAX)
        elif self.BPRP_MIN is not None:
            query += " and (phot_bp_mean_mag-phot_rp_mean_mag)>{}".format(self.BPRP_MIN)
        try:
            job = Gaia.launch_job(query)
            res = job.get_results()
            self.printMessage("Obtained {} stars from Gaia DR3 with G<14 in {:.2f}s".format(len(res),time.time()-t))
        except:
            self.printError("Could not query the Gaia catalog; either the archive is temporarily down or there is a problem with internet access.")
            return catalog
        # First we count the results
        Nstars = 0
        if self.GAIA_RANGE is None:
            Mlo,Mhi = 10,12
            while Mlo<14:
                Nstars = 0
                for obj in res:
                    srcid,ra,dec,pmra,pmdec,mag = obj
                    if mag>=Mlo and mag<=Mhi:
                        Nstars += 1
                if Nstars<self.MINFOPS:
                    Mlo += 0.25
                    Mhi += 0.25
                else:
                    break
            # For the unlikely event of not having enough stars
            if Mlo>=14:
                Mlo = 11.75
                Mhi = 14
                while Mlo>=10:
                    Nstars = 0
                    for obj in res:
                        srcid,ra,dec,pmra,pmdec,mag = obj
                        if mag>=Mlo and mag<=Mhi:
                            Nstars += 1
                    if Nstars<self.MINFOPS:
                        Mlo -= 0.25
                    else:
                        break
        else:
            Mlo,Mhi = self.GAIA_RANGE
        FOPS = {}
        objid = max(catalog)+1
        correction = years*1e-3/3600
        currentNames = [catalog[oid]["name"] for oid in catalog.keys()]
        for obj in res:
            srcid,ra,dec,pmra,pmdec,mag = obj
            srcid = "NWHG "+str(srcid)
            if srcid in currentNames:
                continue
            if mag<Mlo or mag>Mhi:
                continue
            cosDec = cos(dec*pi/180)
            ra += (pmra/cosDec)*correction
            dec += pmdec*correction
            ra = float(ra)%360
            dec = float(dec)
            # Convert RA/Dec to string and back to ensure saved catalogs are the same coords
            strRA = ra2str(ra)
            strDec = dec2str(dec)
            ra = str2deg(strRA)*15
            dec = str2deg(strDec)
            xc,yc,xs,ys = self.skyToPlate(ra,dec)
            FOPS[objid] = {"name":"%s"%(srcid),
                           "mag":"%5.2f"%(mag),
                           "RADeg":ra,
                           "DecDeg":dec,
                           "type":'F',
                           "weight":self.FOPSWEIGHT,
                           "ra":strRA,
                           "dec":strDec,
                           "fibid":None,
                           "slitid":None,
                           "x":xc,
                           "y":yc}
            objid += 1
        return catalog|FOPS

    def setFieldData(self,fieldData):
        self.targetSignal.emit(fieldData)

    def applyCatalog(self,header,catalog):
        # Grab an image, either from cache or download
        imgFile = "{}/{}_{}.jpeg".format(self.cachedir,ra2str(self.FIELDRA).replace(" ",":"),dec2str(self.FIELDDEC).replace(" ",":"))
        worker =  Worker(self.setImage,imgFile)
        self.threadPool.start(worker)

        # Setup the field
        fieldData = {"name":header["FIELDNAME"],
                     "raStr":header["RA"],
                     "decStr":header["DEC"],
                     "angle":float(header["PA"]),
                     "targets":catalog}
        worker2 = Worker(self.setFieldData,fieldData)
        self.threadPool.start(worker2)

        self.catalog = catalog
        self.header = header
        self.cacheKey = self.getCacheKey()
        optFile = self.getOptFile(self.cacheKey)
        self.setupOpt(optFile)
        if self.previousAssignments is not None:
            self.INITIALIZING = True
            for objid,(fibid,flag) in self.previousAssignments.items():
                forceCode = 2 if flag else 0
                fibid = int(fibid)
                self.updateFiberAssignment(objid,fibid,forceCode=forceCode,doShow=False)
            self.INITIALIZING = False
            self.showSelected()


    def getCacheKey(self):
        # Create the cache key to see if we have a pickle'd matrix
        # First, is the header the same?
        hdrKey = [_ for _ in sorted(self.header.items())]
        # Second, is the catalog the same
        catKey = [(key,[(k,v) for k,v in sorted(obj.items()) if k not in ["fibid","slitid"]]) for key,obj in sorted(self.catalog.items())]
        # Finally, are the fibers the same
        fiberKey = [(key,obj["active"]) for key,obj in sorted(self.FiberDB.items())]

        # A unique identifier is the string representation of the
        #   combination of these
        cacheText = (hdrKey+catKey+fiberKey).__repr__()
        # Convert the text to an MD5 hash to save space
        import hashlib
        cacheKey = hashlib.md5(cacheText.encode("utf-8")).hexdigest()
        return cacheKey

    def getOptFile(self,cacheKey=None):
        if not cacheKey:
            cacheKey = self.getCacheKey()
        cachefile = self.cachedir+"/catalog.cache"
        catCache = {}
        if os.path.isfile(cachefile):
            try:
                with open(cachefile,"rb") as F:
                    catCache = pickle.load(F)
            except:
                self.printMessage("Creating new catalog cache: "+cachefile)
        else:
            self.printMessage("Creating new catalog cache: "+cachefile)
        if cacheKey in catCache:
            optFile = catCache[cacheKey]
        else:
            optFile = self.cachedir+"/"+datetime.datetime.now().isoformat()+".pkl"
            catCache[cacheKey] = optFile
            with open(cachefile,"wb") as F:
                pickle.dump(catCache,F,2)
        return optFile

    def setupOpt(self,optFile):
        '''
        Try to load cached collision matrix. If not loaded, recreate it.
        '''
        data = None
        loaded = False
        if os.path.isfile(optFile):
            try:
                with open(optFile,"rb") as F:
                    data = pickle.load(F)
            except:
                pass
        if data:
            try:
                self.fiberLists,self.fiberGeometries,self.footprints,self.idmap,self.weights,self.fibers,self.parkedGeometries,self.objList,self.MATRIX,self.FOPSindex = data
                loaded = True
            except:
                pass
        if not loaded:
            self.setMatrix()
            self.dumpOptFile(optFile)

        # Reset optimization lists
        self.objListWeights = [[] for _ in self.fibers]
        self.zeroCurrentConfig()
        for fibId,objs in enumerate(self.objList):
            wts = [self.weights[i] for i in objs]
            args = sorted(range(len(wts)),key=wts.__getitem__,reverse=True)
            self.objList[fibId] = [objs[i] for i in args]
            self.objListWeights[fibId] = [wts[i] for i in args]
            self.addToCurrentConfig(None,0.,False)

    def dumpOptFile(self,optFile):
        with open(optFile,"wb") as F:
            pickle.dump([self.fiberLists,self.fiberGeometries,self.footprints,self.idmap,self.weights,self.fibers,self.parkedGeometries,self.objList,self.MATRIX,self.FOPSindex],F,2)

    def setImage(self,imgFile):
        img = None
        if os.path.isfile(imgFile):
            from PIL import Image
            try:
                img = Image.open(imgFile)
            except:
                pass
        if img is None:
            self.printMessage("Downloading image")
            img = getPS1Image(self.FIELDRA,self.FIELDDEC,0.,mode=3)
            if img is not None:
                img.save(imgFile)
            else:
                self.printError("Could not download the Pan-STARRS image.")
        if img is not None:
            self.DisplayManager.setImageDirect(img,-self.PA)

    def addTarget(self,ra,dec,objid=None):
        raStr = ra2str(ra)
        decStr = dec2str(dec)
        # We re-calculate the RA/Dec from the strings to get the same
        #  RA/Dec as we would derive from an input catalog. This also
        #  means the refraction corrections are applied to x,y
        ra = str2deg(raStr)*15
        dec = str2deg(decStr)
        _,_,x,y = self.skyToPlate(ra,dec)
        if objid is None:
            objid = -1
            for oid in self.catalog:
                if oid>=objid:
                    objid = oid+1
        self.catalog[objid] = {"name":"PS1 sky",
                              "mag":'99.00',
                              "RADeg":ra,
                              "DecDeg":dec,
                              "type":'S',
                              "weight":0,
                              "ra":raStr,
                              "dec":decStr,
                              "fibid":None,
                              "slitid":None,
                              "x":x,
                              "y":y}
        optID = len(self.idmap)
        self.addCatalogObject(optID,objid,self.catalog[objid])

        for fibId,objs in enumerate(self.objList):
            wts = [self.weights[i] for i in objs]
            args = sorted(range(len(wts)),key=wts.__getitem__,reverse=True)
            self.objList[fibId] = [objs[i] for i in args]
            self.objListWeights[fibId] = [wts[i] for i in args]

        for i in range(len(self.MATRIX)):
            self.MATRIX[i].append(self.getMatrixEntry(i,optID))
        self.MATRIX.append(self.populateMatrixEntries(optID))
        self.updateFiberTable(self.catalog)

    def outputCatalog(self):
        if not self.catalog:
            self.printMessage("No catalog!")
            return
        OK = False
        for index,optID in self.iterateCurrentConfig():
            if optID is not None:
                OK = True
                break
        if not OK:
            self.printMessage("No assigned objects!")
            return
        filename = QFileDialog.getSaveFileName(self,"Select save name",HOME)[0]
        if filename=='':
            return
        if filename.find(".")<0:
            filename += ".hydra"

        F = open(filename,'w')
        for key in self.headerKeywords:
            F.write("{}: {}\n".format(key,self.header[key]))
        F.write("SCORE: %d\n"%(self.currentConfig.score))
        for objid,obj in self.catalog.items():
            F.write("{:>4} {:>30} {:>5} {:>12} {:>12} {:>5} {}".format(objid,obj["name"],obj["mag"],obj["ra"],obj["dec"],obj["weight"],obj["type"]))
            if obj["fibid"]:
                F.write(" {:>3}".format(obj["fibid"]))
                if self.FiberDB[str(obj["fibid"])]["queued"]:
                    F.write("*")
                else:
                    F.write(" ")
                F.write(" # slit={:>2}".format(obj["slitid"]))
            F.write("\n")
        F.close()

        # Also update the pickle cache if the catalogs are updated
        cacheKey = self.getCacheKey()
        if cacheKey!=self.cacheKey:
            optFile = self.getOptFile(cacheKey)
            self.dumpOptFile(optFile)
            self.cacheKey = cacheKey
import random
import time
from math import cos,sin,pi,atan2,sqrt,log
from PyQt6.QtCore import Qt,pyqtSlot,pyqtSignal
import shapely


class FiberPlacer:

    updateOptProgressSignal = pyqtSignal(int)
    updateScoreSignal = pyqtSignal(int)
    REOPT = False
    INITIALIZING = True
    NFOPS = 0
    bestID = []

    def checkCollision(self,fibIndex,optID,fibIndex2,optID2):
        """
        Check if two fiber/ojbect pairs collide.
        """
        if optID2<optID:
            M = self.MATRIX[optID2][optID-(optID2+1)]
            fibA,fibB = fibIndex2,fibIndex
        else:
            M = self.MATRIX[optID][optID2-(optID+1)]
            fibA,fibB = fibIndex,fibIndex2
        # Definite collision
        if M[0]==0:
            return True
        # Possible collision -- compare overlap list
        elif M[0]==2:
            overlaps = M[1][fibA]
            if overlaps&(1<<fibB):
                return True
        return False

    def addObjectToConfiguration(self,fibIndex,optID,forceCode=0):
        # forceCode: 0=Never force, 1=Force non-manual, 2=Force always
        isFOP = fibIndex in self.FOPSindex
        removed = []
        # Is this the same object?
        oldID = self.getCurrentConfigID(fibIndex)
        if oldID==optID and forceCode!=2:
            return None
        # Remove the current assignment if possible
        oldIndex = self.getCurrentConfigIndex(optID)
        if oldIndex is not None:
            if forceCode==0:
                return None
            if forceCode==1 and self.getCurrentConfigFlag(oldIndex)==1:
                return None
            removed.append(oldIndex)

        # Determine which fibers might collide with this assignment
        for fibIndex2,optID2 in self.iterateCurrentConfig():
            flag2 = self.getCurrentConfigFlag(fibIndex2)
            if optID2 is None or optID2==optID or fibIndex2==fibIndex or flag2==2:
                continue
            collide = self.checkCollision(fibIndex,optID,fibIndex2,optID2)
            if collide:
                if forceCode==0 or (forceCode==1 and flag2==True):
                    return None
                removed.append(fibIndex2)
        # Check if we drop below the FOPs limit
        if not self.INITIALIZING:
            NFOPS = self.NFOPS
            for r in removed:
                if r in self.FOPSindex:
                    NFOPS -= 1
            if NFOPS<self.MINFOPS:
                return None
        if isFOP and oldID is None:
            self.NFOPS += 1
        flag = 1 if forceCode==2 else 0
        for r in removed:
            if r in self.FOPSindex:
                self.NFOPS -= 1
            self.updateCurrentConfig(r,None,0.,False)
        self.updateCurrentConfig(fibIndex,optID,self.weights[optID],flag)
        return removed

    def selectObjectForFiber(self,fibIndex):
        # Always select the best possible fiber
        for optID in self.objList[fibIndex]:  
            if self.addObjectToConfiguration(fibIndex,optID) is None:
                continue
            break

    def annealingStep(self,iteration):
        from math import log

        T = (self.T1*(1-iteration/self.MAX)**self.nonlin)+self.T0

        originalConfig = self.copyCurrentConfig()
        tmpNFOPS = self.NFOPS
        # Draw the fiber to assign
        fibIndex = random.choice([index for index,flag in self.iterateCurrentFlags() if flag!=1])
        # Sometimes a fiber won't have any objects associated with it...
        if len(self.objList[fibIndex])==0:
            return
        # Draw the object to assign the fiber to; weight objects based upon
        #  their provided weights
        optID = random.choices(self.objList[fibIndex],self.objListWeights[fibIndex])[0]
        removed = self.addObjectToConfiguration(fibIndex,optID,forceCode=1)
        if removed is None:
            # If removed is none we've collided with a manually placed fiber
            self.restoreCurrentConfig(originalConfig)
            self.NFOPS = tmpNFOPS
            # We've collided with a manually placed fiber
            return
        for r in removed:
            self.selectObjectForFiber(r)

        ratio = self.currentConfig.score-originalConfig.score#score-newScore#sum(self.selectedWeight)-sum(tmpWeights)
        if ratio>=0 or ratio/T>log(random.random()):
            if self.currentConfig.score>self.bestConfig.score:
                self.bestConfig = self.copyCurrentConfig()
        else: # The move was *not* selected, so return to original state
            self.restoreCurrentConfig(originalConfig)
            self.NFOPS = tmpNFOPS

    def optimize(self):
        worker = Worker(self.doOptimize)
        myWindow = ProgressWindow(self,True)
        myWindow.setTitle("Optimizing")
        self.updateOptProgressSignal.connect(myWindow.updateProgress)
        self.updateScoreSignal.connect(myWindow.updateScoreLabel)
        self.threadPool.start(worker)
        myWindow.exec_()
        self.updateOptProgressSignal.disconnect(myWindow.updateProgress)
        self.updateScoreSignal.disconnect(myWindow.updateScoreLabel)
        time.sleep(0.1)
        self.showSelected()#self.selectedID)

    def doOptimize(self,nsteps=20000):
        # First reset all of the objects except manually selected fibers
        self.bestConfig = self.copyCurrentConfig()

        self.NFOPS = 0
        for index,flag in self.iterateCurrentFlags():
            if not flag:
                self.updateCurrentConfig(index,None,0.,False)
            elif index in self.FOPSindex:
                self.NFOPS += 1
        # Now add objects
        self.INITIALIZING = True
        # Start with FOPs
        for index in self.FOPSindex:
            if self.getCurrentConfigID(index) is None:
                self.selectObjectForFiber(index)
        for index,objid in self.iterateCurrentConfig():
            if objid is None:
                self.selectObjectForFiber(index)
        self.INITIALIZING = False
        if self.NFOPS<self.MINFOPS:
            self.printError("Not enough FOPs stars available to create a configuration.")
            self.restoreCurrentConfig(self.bestConfig)
            self.updateOptProgressSignal.emit(100)
            return

        self.T1 = 50.
        self.T0 = 0.
        self.MAX = nsteps
        self.nonlin = 2.

        NTOT = self.MAX*1.5
        NCOUNT = 0
        PCENT = [0,10,20,30,40,50,60,70,80,90]
        tlast = time.time()
        for i in range(self.MAX):
            NCOUNT += 1
            P = int(100*NCOUNT/NTOT)
            if P>=PCENT[0]:
                self.updateOptProgressSignal.emit(int(100*NCOUNT/NTOT))
                del PCENT[0]
            self.annealingStep(i)
            tnow = time.time()
            if tnow-tlast>0.1:
                tlast = tnow
                self.updateScoreSignal.emit(int(self.currentConfig.score))

        self.T1 = 100
        self.T0 = 50
        self.nonlin = 4

        tlast = time.time()
        for i in range(self.MAX//2,self.MAX):
            NCOUNT += 1
            P = int(100*NCOUNT/NTOT)
            if len(PCENT) and P>=PCENT[0]:
                self.updateOptProgressSignal.emit(int(100*NCOUNT/NTOT))
                del PCENT[0]
            self.annealingStep(i)
            tnow = time.time()
            if tnow-tlast>0.1:
                tlast = tnow
                self.updateScoreSignal.emit(int(self.currentConfig.score))
        self.restoreCurrentConfig(self.bestConfig)
        self.updateOptProgressSignal.emit(100)

    def showSelected(self):
        selected = self.currentConfig.IDs
        self.updateBestConfig()
        # Remove all assignments to objects
        for objid in self.catalog.keys():
            self.catalog[objid]["fibid"] = None
            self.catalog[objid]["slitid"] = None

        for fibID in self.fibers:
            sfibID = str(fibID)
            self.FiberDB[sfibID]["object"] = -1
            self.FiberDB[sfibID]["x"] = self.FiberDB[sfibID]["xpark"]
            self.FiberDB[sfibID]["y"] = self.FiberDB[sfibID]["ypark"]
            self.FiberDB[sfibID]["parked"] = True
            self.FiberDB[sfibID]["queued"] = False
        self.updateFiberStatus(self.FiberDB)

        for fibIndex,optID in enumerate(selected):
            if optID is None:
                continue
            fibID = self.fibers[fibIndex]
            objID = self.idmap[optID]
            sfibID = str(fibID)
            self.FiberDB[sfibID]["object"] = objID
            self.FiberDB[sfibID]["x"] = self.catalog[objID]["x"]
            self.FiberDB[sfibID]["y"] = self.catalog[objID]["y"]
            self.FiberDB[sfibID]["queued"] = self.getCurrentConfigFlag(fibIndex)==1#self.selectedFlag[fibIndex]==1
            self.FiberDB[sfibID]["parked"] = False
            self.catalog[objID]["fibid"] = fibID
            self.catalog[objID]["slitid"] = int(self.FiberDB[sfibID]["slit"])

        self.updateFiberStatus(self.FiberDB)
        self.updateFiberTable(self.catalog)


from PyQt6 import *


class PopupWindow(QtWidgets.QWidget):
    def __init__(self, parent=None,addButtonBox=True):
        super().__init__(parent)
        self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose)
        self.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground)

        self.closed = False
        self.setAutoFillBackground(True)
        self.setStyleSheet("""
            PopupWindow {
                background: rgba(64, 64, 64, 64);
            }
            QWidget#container {
                border: 2px solid darkGray;
                border-radius: 4px;
                background: rgb(64, 64, 64);
            }
            QWidget#container > QLabel {
                color: white;
            }
            QLabel#title {
                font-size: 20pt;
            }
            QPushButton#close {
                color: white;
                font-weight: bold;
                background: none;
                border: 1px solid gray;
            }
        """)

        self.fullLayout = QtWidgets.QVBoxLayout(self)

        self.container = QtWidgets.QWidget(autoFillBackground=True, \
                objectName="container")
        self.fullLayout.addWidget(self.container, \
                alignment=QtCore.Qt.AlignmentFlag.AlignCenter)
        self.container.setSizePolicy(QtWidgets.QSizePolicy.Policy.Maximum, \
                QtWidgets.QSizePolicy.Policy.Maximum)

        buttonSize = self.fontMetrics().height()
        self.closeButton = QtWidgets.QPushButton('×', self.container, \
                objectName="close")
        self.closeButton.setFixedSize(buttonSize, buttonSize)
        self.closeButton.clicked.connect(self.close)

        self.layout = QtWidgets.QVBoxLayout(self.container)
        self.layout.setContentsMargins(buttonSize*2,buttonSize,buttonSize*2, \
                buttonSize)

        self.title = QtWidgets.QLabel('',
            objectName="title", alignment=QtCore.Qt.AlignmentFlag.AlignCenter)
        self.layout.addWidget(self.title)

        if addButtonBox:
            self.buttonBox = QtWidgets.QDialogButtonBox(
                QtWidgets.QDialogButtonBox.StandardButton.Ok|QtWidgets.QDialogButtonBox.StandardButton.Cancel)
            self.layout.addWidget(self.buttonBox)
            self.buttonBox.accepted.connect(self.accept)
            self.buttonBox.rejected.connect(self.cancel)
            self.okButton = self.buttonBox.button(self.buttonBox.StandardButton.Ok)
            self.okButton.setEnabled(True)

        parent.installEventFilter(self)

        self.loop = QtCore.QEventLoop(self)

    def setTitle(self,title):
        self.title.setText(title)

    def cancel(self):
        self.loop.exit(False)

    def close(self):
        self.closed = True
        self.loop.quit()

    def showEvent(self, event):
        self.setGeometry(self.parent().rect())

    def resizeEvent(self, event):
        r = self.closeButton.rect()
        r.moveTopRight(self.container.rect().topRight() + QtCore.QPoint(-5, 5))
        self.closeButton.setGeometry(r)

    def accept(self):
        self.loop.exit(True)

    def exec_(self):
        self.show()
        self.raise_()
        res = self.loop.exec()
        self.hide()
        if self.closed:
            return None
        return res

class AssignFiberPopup(PopupWindow):
    def __init__(self,parent,obj=None,fib=None):
        super().__init__(parent)

        if fib is None and obj is None:
            self.setTitle("Assign an Object to a Fiber")
        elif fib is None:
            self.setTitle("Assign Object %s to a Fiber"%(obj))
        else:
            self.setTitle("Assign an Object to Fiber %s"%(fib))

        datacontainer = QtWidgets.QWidget(autoFillBackground=True, \
                objectName="container")
        self.layout.insertWidget(1,datacontainer, \
                alignment=QtCore.Qt.AlignmentFlag.AlignCenter)
        datacontainer.setSizePolicy(QtWidgets.QSizePolicy.Policy.Maximum, \
                QtWidgets.QSizePolicy.Policy.Maximum)

        datalayout = QtWidgets.QGridLayout(datacontainer)
        datalayout.setContentsMargins(8,8,8,8)
        datalayout.setSpacing(4)
        datalayout.addWidget(QtWidgets.QLabel("Object:"),0,0,1,1)
        if obj is None:
            self.object = QtWidgets.QLineEdit()
        else:
            self.object = QtWidgets.QLabel(obj)
        datalayout.addWidget(self.object,0,1,1,1)
        datalayout.addWidget(QtWidgets.QLabel("Fiber:"),1,0,1,1)
        if fib is None:
            self.fiber = QtWidgets.QLineEdit()
        else:
            self.fiber = QtWidgets.QLabel(fib)
        datalayout.addWidget(self.fiber,1,1,1,1)

        if fib is None:
            self.fiber.returnPressed.connect(self.accept)
            self.fiber.setFocus()
        if obj is None:
            self.object.setFocus() # Take focus if both fib and obj are None
            self.object.returnPressed.connect(self.accept)

    def getData(self):
        if self.object.text().strip()!='' and self.fiber.text()!='':
            try:
                return [self.object.text().strip(),self.fiber.text().strip()]
            except:
                return False

    def accept(self):
        if self.getData():
            self.loop.exit(True)


class YesNoPopup(PopupWindow):
    def __init__(self,parent,title):
        super().__init__(parent)

        self.setTitle(title)

        for button in self.buttonBox.buttons():
            self.buttonBox.removeButton(button)
        self.buttonBox.addButton(QtWidgets.QDialogButtonBox.StandardButton.No)
        self.buttonBox.addButton(QtWidgets.QDialogButtonBox.StandardButton.Yes)
        self.buttonBox.setCenterButtons(True)
        self.buttonBox.layout().setDirection(QtWidgets.QBoxLayout.Direction.RightToLeft)

    def accept(self):
        self.loop.exit(True)


class HowManyFibersPopup(PopupWindow):
    def __init__(self,parent,nfibers):
        super().__init__(parent)

        self.setTitle("How many low-weighted fibers\nshould be de-assigned?")
        self.spinBox = QtWidgets.QSpinBox()
        self.spinBox.setRange(1,nfibers)
        self.spinBox.setFixedWidth(50)
        self.layout.insertWidget(1,self.spinBox,alignment=QtCore.Qt.AlignmentFlag.AlignCenter)


    def getData(self):
        return self.spinBox.value()


from PyQt6.QtWidgets import QTableWidgetItem,QLabel,QMenu
from PyQt6.QtGui import QColor,QPixmap
from PyQt6.QtCore import Qt,pyqtSlot,pyqtSignal
from PyQt6 import QtCore
ItemFlag = Qt.ItemFlag
import time




class UpdateHandler:

    @pyqtSlot(dict)
    def updateFiberStatus(self,fiberDB):
        self.DisplayManager.updateFiberDB(fiberDB)

    @pyqtSlot(dict)
    def updateFieldInfo(self,fieldData):
        """
        Update target/field information; this mostly resets the target table,
          but also calls the display manager to update the fiber display.
        """
        if fieldData is None:
            return
        fieldname = fieldData["name"]
        raStr = fieldData["raStr"]
        decStr = fieldData["decStr"]
        targets = fieldData["targets"]
        angle = fieldData["angle"]

        # Set the mapping from plate to sky
        #self.FieldModel.setInfo(fieldData["RA"],fieldData["DEC"],angle)

        T = "%s\nRA:  %s\nDEC: %s"%(fieldname,raStr[:11],decStr[:11])
        self.fieldname_label.setText(T)
        self.fieldinfo.show()
        self.showmarkers.show()
        self.updateFiberTable(targets,angle)

    def updateFiberTable(self,targets,angle=None):
        count = 0
        fibCount = {'F':0,'S':0,'O':0,'C':0}

        # We temporarily disable visibility because rendering the table whilst
        #   building it can be very slow
        self.FiberTable.setVisible(False)
        self.FiberTable.clearContents()
        self.FiberTable.setSortingEnabled(False)
        self.FiberTable.setRowCount(len(targets))
        for count,(objid,data) in enumerate(targets.items()):
            objid = "{:4d}".format(int(objid))
            # Color-code the rows according to target type
            if data["type"]=='F':
                C = QColor("lightYellow")
            elif data["type"]=='S':
                C = QColor(200,200,255)
            else:
                C = QColor(200,255,200)
            tmpItem = QTableWidgetItem(objid)
            tmpItem.setBackground(C)
            tmpItem.setFlags(tmpItem.flags()^ItemFlag.ItemIsEditable)
            self.FiberTable.setItem(count,0,tmpItem)
            for col,val in enumerate(("name","mag","ra","dec")):
                col += 1
                tmpItem = QTableWidgetItem(data[val])
                tmpItem.setBackground(C)
                tmpItem.setFlags(tmpItem.flags()^ItemFlag.ItemIsEditable)
                self.FiberTable.setItem(count,col,tmpItem)
            fibid = ""
            slitid = ""
            # Add fibid/slitid if the target is assigned to a fiber, and
            #  change the color to pink if the fiber is placed.
            if data["fibid"] is not None:
                fibid = "{:3d}".format(data["fibid"])
                fibCount[data["type"]] += 1
                if data["slitid"]>0:
                    slitid = "{:3d}".format(data["slitid"])
                C = QColor("pink")

            # We use a blank QLabel for the fiber/slit in any rows that
            #  DO NOT have a fiber assigned so that they will always be
            #  sorted to the bottom when sorting by fiber/slit.
            colorName = C.name()
            if fibid=="":
                tmpItem = QLabel()
                tmpItem.setStyleSheet("QLabel { background-color: %s }"%(colorName))
                self.FiberTable.setCellWidget(count,5,tmpItem)
            else:
                tmpItem = QTableWidgetItem(fibid)
                tmpItem.setBackground(C)
                tmpItem.setFlags(tmpItem.flags()^ItemFlag.ItemIsEditable)
                self.FiberTable.setItem(count,5,tmpItem)
            if slitid=="":
                tmpItem = QLabel()
                tmpItem.setStyleSheet("QLabel { background-color: %s }"%(colorName))
                self.FiberTable.setCellWidget(count,6,tmpItem)
            else:
                tmpItem = QTableWidgetItem(slitid)
                tmpItem.setBackground(C)
                tmpItem.setFlags(tmpItem.flags()^ItemFlag.ItemIsEditable)
                self.FiberTable.setItem(count,6,tmpItem)
        # Re-enable viewing
        self.FiberTable.setSortingEnabled(True)
        self.FiberTable.setVisible(True)
        # Update the fiber count table
        countStr = "{:3d}".format(fibCount['O'])
        self.fiberCountTable.setItem(1,1,QTableWidgetItem(countStr))
        countStr = "{:3d}".format(fibCount['S'])
        self.fiberCountTable.setItem(2,1,QTableWidgetItem(countStr))
        countStr = "{:3d}".format(fibCount['F'])
        self.fiberCountTable.setItem(3,1,QTableWidgetItem(countStr))
        self.fiberCountTable.setVisible(True)
        # Update the fiber display, including the compasses
        self.DisplayManager.updateTargetDB(targets,angle)

    def fiberTableAction(self,pos):
        """
        """
        row = self.FiberTable.rowAt(pos.y())
        col = self.FiberTable.columnAt(pos.x())
        if self.FiberTable.item(row,0) is None:
            return
        obj = self.FiberTable.item(row,0).text()
        name = self.FiberTable.item(row,1).text()
        fib = self.FiberTable.item(row,5)

        assigned = fib is not None
        menu = QMenu()
        _ = menu.addSection(name.strip())
        blink = menu.addAction("Show marker")
        blink.triggered.connect(lambda: self.DisplayManager.startMarkerBlink(int(obj)))
        if assigned:
            fib = fib.text()
            deassign = menu.addAction("Deassign Fiber")
            deassign.triggered.connect(lambda: self.assignFiber(obj=obj,fib=fib,remove=True))
        menu.exec(self.FiberTable.viewport().mapToGlobal(pos))
from PyQt6 import *


class ProgressWindow(QtWidgets.QWidget):
    def __init__(self, parent=None,addScoreLabel=False):
        super().__init__(parent)
        self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose)
        self.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground)

        self.closed = False
        self.setAutoFillBackground(True)
        self.setStyleSheet("""
            ProgressWindow {
                background: rgba(64, 64, 64, 64);
            }
            QWidget#container {
                border: 2px solid darkGray;
                border-radius: 4px;
                background: rgb(64, 64, 64);
            }
            QWidget#container > QLabel {
                color: white;
            }
            QLabel#title {
                font-size: 20pt;
            }
            QPushButton#close {
                color: white;
                font-weight: bold;
                background: none;
                border: 1px solid gray;
            }
        """)

        self.fullLayout = QtWidgets.QVBoxLayout(self)

        self.container = QtWidgets.QWidget(autoFillBackground=True, \
                objectName="container")
        self.fullLayout.addWidget(self.container, \
                alignment=QtCore.Qt.AlignmentFlag.AlignCenter)
        self.container.setSizePolicy(QtWidgets.QSizePolicy.Policy.Maximum, \
                QtWidgets.QSizePolicy.Policy.Maximum)

        buttonSize = self.fontMetrics().height()

        self.layout = QtWidgets.QVBoxLayout(self.container)
        self.layout.setContentsMargins(buttonSize*2,buttonSize,buttonSize*2, \
                buttonSize)

        self.title = QtWidgets.QLabel('',
            objectName="title", alignment=QtCore.Qt.AlignmentFlag.AlignCenter)
        self.layout.addWidget(self.title)

        self.progressBar = QtWidgets.QProgressBar(self)
        self.layout.addWidget(self.progressBar)

        if addScoreLabel:
            self.scoreLabel = QtWidgets.QLabel('score = 0',objectName="scorelable", alignment=QtCore.Qt.AlignmentFlag.AlignCenter)
            self.layout.addWidget(self.scoreLabel)


        parent.installEventFilter(self)
        self.loop = QtCore.QEventLoop(self)

    def setTitle(self,title):
        self.title.setText(title)

    def showEvent(self, event):
        self.setGeometry(self.parent().rect())

#    def resizeEvent(self, event):
#        r = self.closeButton.rect()
#        r.moveTopRight(self.container.rect().topRight() + QtCore.QPoint(-5, 5))
#        self.closeButton.setGeometry(r)

    @QtCore.pyqtSlot(int)
    def updateProgress(self,percent):
        self.progressBar.setValue(percent)
        if percent==100:
            self.loop.quit()

    @QtCore.pyqtSlot(int)
    def updateScoreLabel(self,score):
        self.scoreLabel.setText("score = {}".format(score))

    def exec_(self):
        self.show()
        self.raise_()
        res = self.loop.exec()
        self.hide()
        if self.closed:
            return None
        return res

from PyQt6.QtCore import QRunnable,QObject,pyqtSignal,pyqtSlot
import traceback,sys
class QtSignals(QObject): 
    error = pyqtSignal(tuple)
    result = pyqtSignal(object)
    finished = pyqtSignal()

class Worker(QRunnable):

    def __init__(self,func,*args):
        super(Worker,self).__init__()

        self.func = func
        self.args = args
        self.signals = QtSignals()

    @pyqtSlot()
    def run(self):
        try:
            result = self.func(*self.args)
        except:
            traceback.print_exc()
            exctype,value = sys.exc_info()[:2]
            self.signals.error.emit((exctype,value,traceback.format_exc()))
        else:
            self.signals.result.emit(result)
        finally:
            self.signals.finished.emit()

import sys,urllib,json
import base64
import requests
from PIL import Image
from math import cos,sin,pi
from multiprocessing import Pool
from io import BytesIO

"""
fname = sys.argv[1]
ra = float(sys.argv[2])
dec = float(sys.argv[3])
rot = float(sys.argv[4])
fov = float(sys.argv[5])
pincushion = float(sys.argv[6])
mode = int(sys.argv[7])
"""


def downloadData(url):
    try:
        response = Image.open(requests.get(url,stream=True).raw)
    except:
        response = None
    return response

def getPS1Image(ra,dec,rot,fov=0.99314,pincushion=77.6,npix=500,mode=0):
    baseURL = "https://alasky.cds.unistra.fr/hips-image-services/hips2fits?hips=CDS%2FP%2FPanSTARRS%2FDR1%2Fcolor-i-r-g&format=jpg&min_cut=0&max_cut=255"

    c = cos(rot*pi/-180)*fov/npix
    s = sin(rot*pi/-180)*fov/npix
    WCS = {"NAXIS1":npix,
           "NAXIS2":npix,
           "WCSAXES":2,
           "CRPIX1":npix/2,
           "CRPIX2":npix/2,
           "CD1_1":-1*c,
           "CD1_2":1*s,
           "CD2_1":-1*s,
           "CD2_2":-1*c,
           "CUNIT1":"deg",
           "CUNIT2":"deg",
           "CTYPE1":"RA---ZPN",
           "CTYPE2":"DEC--ZPN",
           "CRVAL1":ra,
           "CRVAL2":dec}

    WCS['PV2_1'] = 1.
    WCS['PV2_3'] = pincushion


    def makeURL(wcs):
        S = json.dumps(wcs)
        return baseURL+"&"+urllib.parse.urlencode({"wcs":S})


    def getMultipartImage(size):
        wcs = WCS.copy()
        urlList = []

        wcs['NAXIS1'] = size
        wcs['NAXIS2'] = size
        wcs['CD1_1'] *= npix/(size*3)
        wcs['CD1_2'] *= npix/(size*3)
        wcs['CD2_1'] *= npix/(size*3)
        wcs['CD2_2'] *= npix/(size*3)
        for crpix1 in [size+size//2,size//2,size//-2]:
            wcs['CRPIX1'] = crpix1
            for crpix2 in [size+size//2,size//2,size//-2]:
                wcs['CRPIX2'] = crpix2
                urlList.append(makeURL(wcs))

        with Pool(9) as P:
            images = P.map(downloadData,urlList)
        if None in images:
            return None
        img = Image.new('RGB',(size*3,size*3))
        imgCount = 0
        for dx in range(3):
            for dy in [2,1,0]:
                img.paste(images[imgCount],(dx*size,dy*size))
                imgCount += 1
        return img

    if mode==0:
        img = downloadData(makeURL(WCS))
    else:
        img = getMultipartImage(mode*npix)

    return img

    if fname is not None:
        tmp = BytesIO()
        img.save(tmp,'jpeg')
        tmp.seek(0)
        imageStr = base64.b64encode(tmp.read())
        f = open(fname,'w')
        f.write(imageStr.decode("utf-8"))
        f.close()
    return image

def getObjects(ra,dec,radius=1.,glimit=22,rlimit=22):
    from astroquery.mast import Catalogs
    coords = "{} {}".format(ra,dec)
    data = Catalogs.query_criteria(coordinates=coords,radius=radius,catalog="Panstarrs",release="dr2",table="stack",columns=["raStack","decStack"],gMeanApMag=[("lte",glimit)],rMeanApMag=[("lte",rlimit)],primaryDetection=1)
import sys,os,time

from PyQt6.QtWidgets import (
    QApplication, QDialog, QMainWindow, QMessageBox, QTableWidgetItem,
QWidget,QStyleFactory,QHeaderView)
from PyQt6.QtGui import QColor
from PyQt6.QtCore import pyqtSignal,pyqtSlot,Qt,QEvent,QThreadPool,QTimer
from PyQt6.uic import loadUi

from platformdirs import user_cache_dir


class Window(QMainWindow, Ui_MainWindow,CatalogManager,UpdateHandler,FiberInitializer,Astrometry,CollisionMatrix,Configuration,FiberPlacer):

    def __init__(self,parent=None):
        super().__init__(parent)
        self.threadPool = QThreadPool()

        self.setupUi(self)
        self.setWindowFlag(QtCore.Qt.WindowType.WindowSystemMenuHint|QtCore.Qt.WindowType.WindowCloseButtonHint, True)
        self.setWindowTitle("NeWHydra")

        # Make the cache if necessary
        self.cachedir = user_cache_dir("newhydra")
        os.makedirs(self.cachedir,exist_ok=True)



        self.fieldinfo.setStyleSheet("background-color: rgba(255,255,255,0.5); border-radius: 4px;")
        self.fieldname_label.setStyleSheet("background-color: rgba(255,255,255,0.5)")
        self.coords.setStyleSheet("background-color: rgba(255,255,255,0.4); border-radius: 4px;")
        self.showmarkers.setStyleSheet("background-color: rgba(255,255,255,0.4); border-radius: 4px;")
        self.fieldinfo.hide()
        self.showmarkers.hide()

        self.fiberCountTable.setSpan(0,0,1,2)
        self.fiberCountTable.setStyleSheet("""
        QTableWidget { border: 0px solid black;
                        background: rgba(255,255,255,0.6)}
        QTableWidget::item { border: 1px solid black; }
        """)
        self.fiberCountTable.setColumnWidth(0,45)
        self.fiberCountTable.setColumnWidth(1,35)
        self.fiberCountTable.hide()


        self.markersLayout.addStretch()
        self.showfops_cbox.setStyleSheet("background-color: rgba(255,255,255,0)")
        self.showtargets_cbox.setStyleSheet("background-color: rgba(255,255,255,0)")
        self.showskys_cbox.setStyleSheet("background-color: rgba(255,255,255,0)")
        self.ps1image_cbox.setStyleSheet("background-color: rgba(255,255,255,0)")
        # Disable the PS1 image checkbox until an image is loaded
        self.ps1image_cbox.setEnabled(False)

        self.xcoord_label.setIndent(3)
        self.ycoord_label.setIndent(3)

        self.printMessageSignal.connect(self.printMessage)
        self.fiberSignal.connect(self.updateFiberStatus)
        self.targetSignal.connect(self.updateFieldInfo)

        configFile = os.path.join(os.path.dirname(__file__),'Hydra.config')
        self.HydraConfig = eval(open(configFile).read())
        self.sitePars = self.HydraConfig["WIYN"]
        self.setButtons()

        concenFile = os.path.join(os.path.dirname(__file__),'hydraConcentricities.json')
        self.getConcentricities(concenFile)
        self.DisplayManager = FiberDisplayManager(self)

        self.DisplayManager.updateFiberDB(self.FiberDB)

        self.setupTable()
        self.loadField_btn.clicked.connect(self.loadFieldFile)

        self.optimize_btn.clicked.connect(self.optimize)
        self.makeSkies_btn.clicked.connect(self.removeLowestWeightedFibers)

        self.saveConfig_btn.clicked.connect(self.outputCatalog)

        self.resetCurrentConfig()
        self.reset_btn.clicked.connect(self.resetPopup)

        self.showUnassigned_btn.clicked.connect(self.DisplayManager.startUnassignedBlink)

        self.messageBox.setStyleSheet("font: 9pt 'Courier';")


        # We place the prompt() call behind a QTimer to give the
        #  GUI a chance to come up before the OpenFile dialog
        QTimer.singleShot(50,self.prompt)

    def prompt(self):
        if len(sys.argv)>1:
            self.loadFieldFile(filename=sys.argv[1])
        else:
            self.loadFieldFile()

    def changeEvent(self,event):
        # Prevent window from moving to upper-left corner on maximize event
        if event.type()==QEvent.Type.WindowStateChange:
            if self.windowState()==Qt.WindowState.WindowMaximized:
                self.showNormal()
    
    def assignFiber(self,obj=None,fib=None,remove=False):
        if obj is not None and type(obj)==int:
            obj = str(obj)
        if fib is not None and type(fib)==int:
            fib = str(fib)
        if fib is not None and obj is not None and not remove:
            popup = YesNoPopup(self,"Do you want to assign Fiber %s to Object %s?"%(fib,obj))
        elif remove and (fib is not None or obj is not None):
            if obj is not None:
                popup = YesNoPopup(self,"Do you want to remove the fiber assignment from Object %s?"%(obj.strip()))
                fib = "-1"
            else:
                popup = YesNoPopup(self,"Do you want to remove the object assigned to Fiber %s?"%(fib))
                obj = "-1"
        else:
            popup = AssignFiberPopup(self,obj=obj,fib=fib)
        if popup.exec_():
            if fib is None or obj is None:
                obj,fib = popup.getData()
            fib = fib.strip()
            obj = obj.strip()
            return self.updateFiberAssignment(int(obj),int(fib),remove)

    def updateFiberAssignment(self,objID,fibID,remove=False,forceCode=2,doShow=True):
        fibIndex = None
        if objID==-1:
            fibIndex = self.fibers.index(fibID)
            remove = True
        elif fibID==-1:
            optID = self.idmap.index(objID)
            fibIndex = self.getCurrentConfigIndex(optID)
            remove = True
        if remove:
            if fibIndex is None:
                fibIndex = self.fibers.index(fibID)
            self.updateCurrentConfig(fibIndex,None,0,False)
        else:
            optID = self.idmap.index(objID)
            fibIndex = self.fibers.index(fibID)
            if optID in self.objList[fibIndex]:
                result = self.addObjectToConfiguration(fibIndex,optID,forceCode=forceCode)
                if result is None:
                    self.printError("Fiber {} could not be assigned.".format(fibID))
                    return False
            else:
                self.printError("Fiber {} could not be assigned.".format(fibID))
                return False
        if doShow:
            self.showSelected()
        return True

    def removeLowestWeightedFibers(self):

        fibs,wts = [],[]
        for index,optID in self.iterateCurrentConfig():
            if optID is None or self.getCurrentConfigFlag(index) or index in self.FOPSindex:
                continue
            fibs.append(index)
            wts.append(self.getCurrentConfigWeight(index))
        if not fibs:
            return
        args = sorted(range(len(wts)),key=wts.__getitem__,reverse=False)
        popup = HowManyFibersPopup(self,len(fibs))
        if popup.exec_():
            N = popup.getData()
            for index in args[:N]:
                fibIndex = fibs[index]
                self.updateCurrentConfig(fibIndex,None,0,False)
            self.showSelected()

    def resetPopup(self):
        popup = YesNoPopup(self,"Do you also want to reset\nmanually placed fibers?")
        answer = popup.exec_()
        if answer is not None:
            self.resetCurrentConfig(removeManual=answer)
            self.showSelected()

    def str2deg(self,instr):
        instr = instr.replace(":"," ")
        d,m,s = instr.split()
        sign = -1 if d[0]=='-' else 1
        return sign*(abs(float(d))+float(m)/60+float(s)/3600)

    def ra2str(self,ra,sep=' '):
        H = ra/15.
        h = int(H)
        m = int((H-h)*60)
        s = ((H-h)*60-m)*60
        return "%02d%s%02d%s%06.3f"%(h,sep,m,sep,s)

    def dec2str(self,dec,sep=' '):
        sign = "+" if dec>=0 else "-"
        dec = abs(dec)
        d = int(dec)
        m = int((dec-d)*60)
        s = ((dec-d)*60-m)*60
        return "%s%02d%s%02d%s%05.2f"%(sign,d,sep,m,sep,s)

    @pyqtSlot(str)
    def printMessage(self,*kargs):
        message = " ".join(kargs)
        print(message)
        self.messageBox.append("> "+message)

    def printError(self,*kargs):
        message = " ".join(kargs)
        print(message)
        message = "<font color='red'>%s</font>"%(message)
        self.messageBox.append("> "+message)


if __name__ == "__main__":
    app = QApplication(sys.argv)
    win = Window()
    win.show()
    sys.exit(app.exec())
