"""
    tr(r)

Transpose an r-matrix.

### Given ###

- `r`: R-matrix

### Returned ###

- `rt`: Transpose

### Note ###

   It is permissible for r and rt to be the same array.

### Called ###

- `eraCr`: copy r-matrix

"""
function tr(r)
    rt = zeros((3, 3))
    ccall((:eraTr, liberfa), Cvoid,
          (Ptr{Cdouble}, Ptr{Cdouble}),
          r, rt)
    rt
end

"""
    trxpv(r, pv)

Multiply a pv-vector by the transpose of an r-matrix.

### Given ###

- `r`: R-matrix
- `pv`: Pv-vector

### Returned ###

- `trpv`: R * pv

### Note ###

   It is permissible for pv and trpv to be the same array.

### Called ###

- `eraTr`: transpose r-matrix
- `eraRxpv`: product of r-matrix and pv-vector

"""
function trxpv(r, p)
    rp = zeros((2, 3))
    ccall((:eraTrxpv, liberfa), Cvoid,
            (Ptr{Cdouble}, Ptr{Cdouble}, Ptr{Cdouble}),
            r, p, rp)
    rp
end

"""
    trxp(r, p)

Multiply a p-vector by the transpose of an r-matrix.

### Given ###

- `r`: R-matrix
- `p`: P-vector

### Returned ###

- `trp`: R * p

### Note ###

   It is permissible for p and trp to be the same array.

### Called ###

- `eraTr`: transpose r-matrix
- `eraRxp`: product of r-matrix and p-vector

"""
function trxp(r, p)
    rp = zeros(3)
    ccall((:eraTrxp, liberfa), Cvoid,
            (Ptr{Cdouble}, Ptr{Cdouble}, Ptr{Cdouble}),
            r, p, rp)
    rp
end

"""
    taiut1(tai1, tai2, dta)

Time scale transformation:  International Atomic Time, TAI, to
Universal Time, UT1.

### Given ###

- `tai1`, `tai2`: TAI as a 2-part Julian Date
- `dta`: UT1-TAI in seconds

### Returned ###

- `ut11`, `ut12`: UT1 as a 2-part Julian Date

### Notes ###

1. tai1+tai2 is Julian Date, apportioned in any convenient way
   between the two arguments, for example where tai1 is the Julian
   Day Number and tai2 is the fraction of a day.  The returned
   UT11,UT12 follow suit.

2. The argument dta, i.e. UT1-TAI, is an observed quantity, and is
   available from IERS tabulations.

### Reference ###

- Explanatory Supplement to the Astronomical Almanac,
    P. Kenneth Seidelmann (ed), University Science Books (1992)

"""
taiut1

"""
    tdbtt(tdb1, tdb2, dtr)

Time scale transformation:  Barycentric Dynamical Time, TDB, to
Terrestrial Time, TT.

### Given ###

- `tdb1`, `tdb2`: TDB as a 2-part Julian Date
- `dtr`: TDB-TT in seconds

### Returned ###

- `tt1`, `tt2`: TT as a 2-part Julian Date

### Notes ###

1. tdb1+tdb2 is Julian Date, apportioned in any convenient way
   between the two arguments, for example where tdb1 is the Julian
   Day Number and tdb2 is the fraction of a day.  The returned
   tt1,tt2 follow suit.

2. The argument dtr represents the quasi-periodic component of the
   GR transformation between TT and TCB.  It is dependent upon the
   adopted solar-system ephemeris, and can be obtained by numerical
   integration, by interrogating a precomputed time ephemeris or by
   evaluating a model such as that implemented in the ERFA function
   eraDtdb.   The quantity is dominated by an annual term of 1.7 ms
   amplitude.

3. TDB is essentially the same as Teph, the time argument for the
   JPL solar system ephemerides.

### References ###

- McCarthy, D. D., Petit, G. (eds.), IERS Conventions (2003),
    IERS Technical Note No. 32, BKG (2004)

- IAU 2006 Resolution 3

"""
tdbtt

"""
    tttdb(tt1, tt2, dtr)

Time scale transformation:  Terrestrial Time, TT, to Barycentric
Dynamical Time, TDB.

### Given ###

- `tt1`, `tt2`: TT as a 2-part Julian Date
- `dtr`: TDB-TT in seconds

### Returned ###

- `tdb1`, `tdb2`: TDB as a 2-part Julian Date

### Notes ###

1. tt1+tt2 is Julian Date, apportioned in any convenient way between
   the two arguments, for example where tt1 is the Julian Day Number
   and tt2 is the fraction of a day.  The returned tdb1,tdb2 follow
   suit.

2. The argument dtr represents the quasi-periodic component of the
   GR transformation between TT and TCB.  It is dependent upon the
   adopted solar-system ephemeris, and can be obtained by numerical
   integration, by interrogating a precomputed time ephemeris or by
   evaluating a model such as that implemented in the ERFA function
   eraDtdb.   The quantity is dominated by an annual term of 1.7 ms
   amplitude.

3. TDB is essentially the same as Teph, the time argument for the JPL
   solar system ephemerides.

### References ###

- McCarthy, D. D., Petit, G. (eds.), IERS Conventions (2003),
    IERS Technical Note No. 32, BKG (2004)

- IAU 2006 Resolution 3

"""
tttdb

"""
    ttut1(tt1, tt2, dt)

Time scale transformation:  Terrestrial Time, TT, to Universal Time,
UT1.

### Given ###

- `tt1`, `tt2`: TT as a 2-part Julian Date
- `dt`: TT-UT1 in seconds

### Returned ###

- `ut11`, `ut12`: UT1 as a 2-part Julian Date

### Notes ###

1. tt1+tt2 is Julian Date, apportioned in any convenient way between
   the two arguments, for example where tt1 is the Julian Day Number
   and tt2 is the fraction of a day.  The returned ut11,ut12 follow
   suit.

2. The argument dt is classical Delta T.

### Reference ###

- Explanatory Supplement to the Astronomical Almanac,
    P. Kenneth Seidelmann (ed), University Science Books (1992)

"""
ttut1

for name in ("taiut1",
             "tdbtt",
             "tttdb",
             "ttut1")
    f = Symbol(name)
    fc = "era" * uppercasefirst(name)
    @eval begin
        function ($f)(a, b, c)
            r1 = Ref(0.0)
            r2 = Ref(0.0)
            i = ccall(($fc, liberfa), Cint,
                      (Cdouble, Cdouble, Cdouble, Ref{Cdouble}, Ref{Cdouble}),
                      a, b, c, r1, r2)
            @assert i == 0
            r1[], r2[]
        end
    end
end

"""
    taitt(tai1, tai2)

Time scale transformation:  International Atomic Time, TAI, to
Terrestrial Time, TT.

### Given ###

- `tai1`, `tai2`: TAI as a 2-part Julian Date

### Returned ###

- `tt1`, `tt2`: TT as a 2-part Julian Date

### Note ###

   tai1+tai2 is Julian Date, apportioned in any convenient way
   between the two arguments, for example where tai1 is the Julian
   Day Number and tai2 is the fraction of a day.  The returned
   tt1,tt2 follow suit.

### References ###

- McCarthy, D. D., Petit, G. (eds.), IERS Conventions (2003),
    IERS Technical Note No. 32, BKG (2004)

- Explanatory Supplement to the Astronomical Almanac,
    P. Kenneth Seidelmann (ed), University Science Books (1992)

"""
taitt

"""
    taiutc(tai1, tai2)

Time scale transformation:  International Atomic Time, TAI, to
Coordinated Universal Time, UTC.

### Given ###

- `tai1`, `tai2`: TAI as a 2-part Julian Date (Note 1)

### Returned ###

- `utc1`, `utc2`: UTC as a 2-part quasi Julian Date (Notes 1-3)

### Notes ###

1. tai1+tai2 is Julian Date, apportioned in any convenient way
   between the two arguments, for example where tai1 is the Julian
   Day Number and tai2 is the fraction of a day.  The returned utc1
   and utc2 form an analogous pair, except that a special convention
   is used, to deal with the problem of leap seconds - see the next
   note.

2. JD cannot unambiguously represent UTC during a leap second unless
   special measures are taken.  The convention in the present
   function is that the JD day represents UTC days whether the
   length is 86399, 86400 or 86401 SI seconds.  In the 1960-1972 era
   there were smaller jumps (in either direction) each time the
   linear UTC(TAI) expression was changed, and these "mini-leaps"
   are also included in the ERFA convention.

3. The function eraD2dtf can be used to transform the UTC quasi-JD
   into calendar date and clock time, including UTC leap second
   handling.

4. The warning status "dubious year" flags UTCs that predate the
   introduction of the time scale or that are too far in the future
   to be trusted.  See eraDat for further details.

### Called ###

- `eraUtctai`: UTC to TAI

### References ###

- McCarthy, D. D., Petit, G. (eds.), IERS Conventions (2003),
    IERS Technical Note No. 32, BKG (2004)

- Explanatory Supplement to the Astronomical Almanac,
    P. Kenneth Seidelmann (ed), University Science Books (1992)

"""
taiutc

"""
    tcbtdb(tcb1, tcb2)

Time scale transformation:  Barycentric Coordinate Time, TCB, to
Barycentric Dynamical Time, TDB.

### Given ###

- `tcb1`, `tcb2`: TCB as a 2-part Julian Date

### Returned ###

- `tdb1`, `tdb2`: TDB as a 2-part Julian Date

### Notes ###

1. tcb1+tcb2 is Julian Date, apportioned in any convenient way
   between the two arguments, for example where tcb1 is the Julian
   Day Number and tcb2 is the fraction of a day.  The returned
   tdb1,tdb2 follow suit.

2. The 2006 IAU General Assembly introduced a conventional linear
   transformation between TDB and TCB.  This transformation
   compensates for the drift between TCB and terrestrial time TT,
   and keeps TDB approximately centered on TT.  Because the
   relationship between TT and TCB depends on the adopted solar
   system ephemeris, the degree of alignment between TDB and TT over
   long intervals will vary according to which ephemeris is used.
   Former definitions of TDB attempted to avoid this problem by
   stipulating that TDB and TT should differ only by periodic
   effects.  This is a good description of the nature of the
   relationship but eluded precise mathematical formulation.  The
   conventional linear relationship adopted in 2006 sidestepped
   these difficulties whilst delivering a TDB that in practice was
   consistent with values before that date.

3. TDB is essentially the same as Teph, the time argument for the
   JPL solar system ephemerides.

### Reference ###

- IAU 2006 Resolution B3

"""
tcbtdb

"""
    tcgtt(tcg1, tcg2)

Time scale transformation:  Geocentric Coordinate Time, TCG, to
Terrestrial Time, TT.

### Given ###

- `tcg1`, `tcg2`: TCG as a 2-part Julian Date

### Returned ###

- `tt1`, `tt2`: TT as a 2-part Julian Date

### Note ###

   tcg1+tcg2 is Julian Date, apportioned in any convenient way
   between the two arguments, for example where tcg1 is the Julian
   Day Number and tcg22 is the fraction of a day.  The returned
   tt1,tt2 follow suit.

### References ###

- McCarthy, D. D., Petit, G. (eds.), IERS Conventions (2003),.
    IERS Technical Note No. 32, BKG (2004)

- IAU 2000 Resolution B1.9

"""
tcgtt

"""
    tdbtcb(tdb1, tdb2)

Time scale transformation:  Barycentric Dynamical Time, TDB, to
Barycentric Coordinate Time, TCB.

### Given ###

- `tdb1`, `tdb2`: TDB as a 2-part Julian Date

### Returned ###

- `tcb1`, `tcb2`: TCB as a 2-part Julian Date

### Notes ###

1. tdb1+tdb2 is Julian Date, apportioned in any convenient way
   between the two arguments, for example where tdb1 is the Julian
   Day Number and tdb2 is the fraction of a day.  The returned
   tcb1,tcb2 follow suit.

2. The 2006 IAU General Assembly introduced a conventional linear
   transformation between TDB and TCB.  This transformation
   compensates for the drift between TCB and terrestrial time TT,
   and keeps TDB approximately centered on TT.  Because the
   relationship between TT and TCB depends on the adopted solar
   system ephemeris, the degree of alignment between TDB and TT over
   long intervals will vary according to which ephemeris is used.
   Former definitions of TDB attempted to avoid this problem by
   stipulating that TDB and TT should differ only by periodic
   effects.  This is a good description of the nature of the
   relationship but eluded precise mathematical formulation.  The
   conventional linear relationship adopted in 2006 sidestepped
   these difficulties whilst delivering a TDB that in practice was
   consistent with values before that date.

3. TDB is essentially the same as Teph, the time argument for the
   JPL solar system ephemerides.

### Reference ###

- IAU 2006 Resolution B3

"""
tdbtcb

"""
    tttai(tt1, tt2)

Time scale transformation:  Terrestrial Time, TT, to International
Atomic Time, TAI.

### Given ###

- `tt1`, `tt2`: TT as a 2-part Julian Date

### Returned ###

- `tai1`, `tai2`: TAI as a 2-part Julian Date

### Note ###

   tt1+tt2 is Julian Date, apportioned in any convenient way between
   the two arguments, for example where tt1 is the Julian Day Number
   and tt2 is the fraction of a day.  The returned tai1,tai2 follow
   suit.

### References ###

- McCarthy, D. D., Petit, G. (eds.), IERS Conventions (2003),
    IERS Technical Note No. 32, BKG (2004)

- Explanatory Supplement to the Astronomical Almanac,
    P. Kenneth Seidelmann (ed), University Science Books (1992)

"""
tttai

"""
    tttcg(tt1, tt2)

Time scale transformation:  Terrestrial Time, TT, to Geocentric
Coordinate Time, TCG.

### Given ###

- `tt1`, `tt2`: TT as a 2-part Julian Date

### Returned ###

- `tcg1`, `tcg2`: TCG as a 2-part Julian Date

### Note ###

   tt1+tt2 is Julian Date, apportioned in any convenient way between
   the two arguments, for example where tt1 is the Julian Day Number
   and tt2 is the fraction of a day.  The returned tcg1,tcg2 follow
   suit.

### References ###

- McCarthy, D. D., Petit, G. (eds.), IERS Conventions (2003),
    IERS Technical Note No. 32, BKG (2004)

- IAU 2000 Resolution B1.9

"""
tttcg

for name in ("taitt",
             "taiutc",
             "tcbtdb",
             "tcgtt",
             "tdbtcb",
             "tttai",
             "tttcg")
    f = Symbol(name)
    fc = "era" * uppercasefirst(name)
    @eval begin
        function ($f)(a, b)
            r1 = Ref(0.0)
            r2 = Ref(0.0)
            i = ccall(($fc, liberfa), Cint,
                      (Cdouble, Cdouble, Ref{Cdouble}, Ref{Cdouble}),
                      a, b, r1, r2)
            @assert i == 0
            r1[], r2[]
        end
    end
end

"""
    tf2a(s, ihour, imin, sec)

Convert hours, minutes, seconds to radians.

### Given ###

- `s`: Sign:  '-' = negative, otherwise positive
- `ihour`: Hours
- `imin`: Minutes
- `sec`: Seconds

### Returned ###

- `rad`: Angle in radians

### Notes ###

1.  The result is computed even if any of the range checks fail.

2.  Negative ihour, imin and/or sec produce a warning status, but
    the absolute value is used in the conversion.

3.  If there are multiple errors, the status value reflects only the
    first, the smallest taking precedence.

"""
tf2a

"""
    tf2d(s, ihour, imin, sec)

Convert hours, minutes, seconds to days.

### Given ###

- `s`: Sign:  '-' = negative, otherwise positive
- `ihour`: Hours
- `imin`: Minutes
- `sec`: Seconds

### Returned ###

- `days`: Interval in days

### Notes ###

1.  The result is computed even if any of the range checks fail.

2.  Negative ihour, imin and/or sec produce a warning status, but
    the absolute value is used in the conversion.

3.  If there are multiple errors, the status value reflects only the
    first, the smallest taking precedence.

"""
tf2d

for name in ("tf2a",
             "tf2d")
    f = Symbol(name)
    fc = "era" * uppercasefirst(name)
    @eval begin
        function ($f)(s, ideg, iamin, asec)
            rad = Ref(0.0)
            i = ccall(($fc, liberfa), Cint,
                      (Cchar, Cint, Cint, Cdouble, Ref{Cdouble}),
                       s, ideg, iamin, asec, rad)
            if i == 1
                throw(ERFAException("ihour outside range 0-23"))
            elseif i == 2
                throw(ERFAException("imin outside range 0-59"))
            elseif i == 3
                throw(ERFAException("sec outside range 0-59.999..."))
            end
            rad[]
        end
    end
end


using GridapGmsh
using GridapGmsh: gmsh, GmshDiscreteModel

function add_pin!(gmsh, o, r, t, lc)
    factory = gmsh.model.geo

    # inner and outer circle
    l1 = add_circle!(gmsh, o, r, lc)
    l2 = add_circle!(gmsh, o, r + t, lc)

    s1 = factory.addPlaneSurface([l1])
    s2 = factory.addPlaneSurface([l2, l1]) # l1 is a hole

    return s1, s2
end

function add_circle!(gmsh, o, r, lc)
    factory = gmsh.model.geo
    ox, oy = o

    p1 = factory.addPoint(ox, oy, 0, lc)  # center - origin
    p2 = factory.addPoint(ox + r, oy, 0, lc)  # right
    p3 = factory.addPoint(ox, oy + r, 0, lc)  # up
    p4 = factory.addPoint(ox - r, oy, 0, lc)  # left
    p5 = factory.addPoint(ox, oy - r, 0, lc)  # down

    c1 = factory.addCircleArc(p2, p1, p3)
    c2 = factory.addCircleArc(p3, p1, p4)
    c3 = factory.addCircleArc(p4, p1, p5)
    c4 = factory.addCircleArc(p5, p1, p2)

    l1 = factory.addCurveLoop([c1, c2, c3, c4])

    return l1
end

function add_square!(gmsh, s)
    factory = gmsh.model.geo

    p1 = factory.addPoint(0, 0, 0, lc)
    p2 = factory.addPoint(s, 0, 0, lc)
    p3 = factory.addPoint(s, s, 0, lc)
    p4 = factory.addPoint(0, s, 0, lc)

    l1 = factory.addLine(p1, p2)
    l2 = factory.addLine(p2, p3)
    l3 = factory.addLine(p3, p4)
    l4 = factory.addLine(p4, p1)

    cl1 = factory.addCurveLoop([l1, l2, l3, l4])

    return cl1
end

N = 17

# in cm
p = 1.26
r = 0.54
lc = 0.5

gmsh.initialize()
gmsh.model.add("c5g7")

# tags
guidetube = Int32[]
UO₂ = Int32[]
MOX_43 = Int32[]
MOX_7 = Int32[]
MOX_87 = Int32[]
fissionchamber = Int32[]

GT_pos = [( 3, 6), ( 3, 9), (3, 12),
          ( 4, 4), ( 4, 14),
          ( 6, 3), ( 6, 6), ( 6, 9), ( 6, 12), ( 6, 15),
          ( 9, 3), ( 9, 6),          ( 9, 12), ( 9, 15),
          (12, 3), (12, 6), (12, 9), (12, 12), (12, 15),
          (14, 4), (14, 14),
          (15, 6), (15, 9), (15, 12)]

FC_pos = [(9, 9)]

# UO₂ fuel assemblies
for n in 1:2
    # corner
    xc = (n - 1) * N * p
    yc = (n - 1) * N * p

    for i in 1:N, j in 1:N
        xo = xc + p / 2 + (i - 1) * p
        yo = yc + p / 2 + (j - 1) * p

        tag = add_circle!(gmsh, (xo, yo), r, lc)
        gmsh.model.geo.addPlaneSurface([tag])

        pos = (i, j)
        if pos in GT_pos
            push!(guidetube, tag)
        elseif pos in FC_pos
            push!(fissionchamber, tag)
        else
            push!(UO₂, tag)
        end
    end
end

MOX_43_pos = Tuple{Int64,Int64}[]
for i in 1:N, j in 1:N
    if i == 1 || i == N || j == 1 || j == N
        push!(MOX_43_pos, (i, j))
    end
end

MOX_87_pos = Tuple{Int64,Int64}[]
for i in 4:N-3, j in 4:N-3
    pos = (i, j)
    if pos in GT_pos
        continue
    elseif pos in FC_pos
        continue
    elseif pos in ((5, 4), (13, 4), (4, 5), (14, 5), (4, 13), (14, 13), (5, 14), (13, 14))
        continue
    else
        push!(MOX_87_pos, pos)
    end
end

MOX_7_pos = Tuple{Int64,Int64}[]
for i in 1:N, j in 1:N
    pos = (i, j)
    if pos in GT_pos
        continue
    elseif pos in FC_pos
        continue
    elseif pos in MOX_43_pos
        continue
    elseif pos in MOX_87_pos
        continue
    # elseif pos in ((5, 4), (13, 4), (4, 5), (14, 5), (4, 13), (14, 13), (5, 14), (13, 14))
    #     push!(MOX_7_pos, pos)
    else
        push!(MOX_7_pos, pos)
    end
end

# MOX fuel assemblies
for n in 1:2
    # corner
    xc = isone(n) ? N * p : 0
    yc = isone(n) ? 0 : N * p

    for i in 1:N, j in 1:N
        xo = xc + p / 2 + (i - 1) * p
        yo = yc + p / 2 + (j - 1) * p

        tag = add_circle!(gmsh, (xo, yo), r, lc)
        gmsh.model.geo.addPlaneSurface([tag])

        pos = (i, j)
        if pos in GT_pos
            push!(guidetube, tag)
        elseif pos in MOX_43_pos
            push!(MOX_43, tag)
        elseif pos in MOX_7_pos
            push!(MOX_7, tag)
        elseif pos in MOX_87_pos
            push!(MOX_87, tag)
        elseif pos in FC_pos
            push!(fissionchamber, tag)
        end
    end
end

s = 2 * N * p
lc = 0.6
factory = gmsh.model.geo

p1 = factory.addPoint(0, 0, 0, lc)
p2 = factory.addPoint(s, 0, 0, lc)
p3 = factory.addPoint(s, s, 0, lc)
p4 = factory.addPoint(0, s, 0, lc)

ml1 = factory.addLine(p1, p2)
ml2 = factory.addLine(p2, p3)
ml3 = factory.addLine(p3, p4)
ml4 = factory.addLine(p4, p1)

cl1 = factory.addCurveLoop([ml1, ml2, ml3, ml4])

moderator = factory.addPlaneSurface(vcat(cl1, guidetube, fissionchamber, UO₂, MOX_43, MOX_7, MOX_87))

s = 2 * N * p
δs = 1 / 3 * N * p
lc = 1.
factory = gmsh.model.geo

p1 = factory.addPoint(s, 0, 0, lc)
p2 = factory.addPoint(s, s, 0, lc)
p3 = factory.addPoint(0, s, 0, lc)
p4 = factory.addPoint(0, s + δs, 0, lc)
p5 = factory.addPoint(s + δs, s + δs, 0, lc)
p6 = factory.addPoint(s + δs, 0, 0, lc)

r1l1 = factory.addLine(p1, p2)
r1l2 = factory.addLine(p2, p3)
r1l3 = factory.addLine(p3, p4)
r1l4 = factory.addLine(p4, p5)
r1l5 = factory.addLine(p5, p6)
r1l6 = factory.addLine(p6, p1)

cl1 = factory.addCurveLoop([r1l1, r1l2, r1l3, r1l4, r1l5, r1l6])

ref1 = factory.addPlaneSurface([cl1])

s += δs
δs = 1 / 3 * N * p
lc = 1.5
factory = gmsh.model.geo

p1 = factory.addPoint(s, 0, 0, lc)
p2 = factory.addPoint(s, s, 0, lc)
p3 = factory.addPoint(0, s, 0, lc)
p4 = factory.addPoint(0, s + δs, 0, lc)
p5 = factory.addPoint(s + δs, s + δs, 0, lc)
p6 = factory.addPoint(s + δs, 0, 0, lc)

r2l1 = factory.addLine(p1, p2)
r2l2 = factory.addLine(p2, p3)
r2l3 = factory.addLine(p3, p4)
r2l4 = factory.addLine(p4, p5)
r2l5 = factory.addLine(p5, p6)
r2l6 = factory.addLine(p6, p1)

cl1 = factory.addCurveLoop([r2l1, r2l2, r2l3, r2l4, r2l5, r2l6])

ref2 = factory.addPlaneSurface([cl1])

s += δs
δs = 1 / 3 * N * p
lc = 2.
factory = gmsh.model.geo

p1 = factory.addPoint(s, 0, 0, lc)
p2 = factory.addPoint(s, s, 0, lc)
p3 = factory.addPoint(0, s, 0, lc)
p4 = factory.addPoint(0, s + δs, 0, lc)
p5 = factory.addPoint(s + δs, s + δs, 0, lc)
p6 = factory.addPoint(s + δs, 0, 0, lc)

r3l1 = factory.addLine(p1, p2)
r3l2 = factory.addLine(p2, p3)
r3l3 = factory.addLine(p3, p4)
r3l4 = factory.addLine(p4, p5)
r3l5 = factory.addLine(p5, p6)
r3l6 = factory.addLine(p6, p1)

cl1 = factory.addCurveLoop([r3l1, r3l2, r3l3, r3l4, r3l5, r3l6])

ref3 = factory.addPlaneSurface([cl1])

pg1 = gmsh.model.geo.addPhysicalGroup(2, guidetube)
pg2 = gmsh.model.geo.addPhysicalGroup(2, fissionchamber)
pg3 = gmsh.model.geo.addPhysicalGroup(2, UO₂)
pg4 = gmsh.model.geo.addPhysicalGroup(2, MOX_43)
pg5 = gmsh.model.geo.addPhysicalGroup(2, MOX_7)
pg6 = gmsh.model.geo.addPhysicalGroup(2, MOX_87)
pg7 = gmsh.model.geo.addPhysicalGroup(2, [moderator, ref1, ref2, ref3])
pg8 = gmsh.model.geo.addPhysicalGroup(1, [ml1, r1l6, r2l6, r3l6])
pg9 = gmsh.model.geo.addPhysicalGroup(1, [ml4, r1l3, r2l3, r3l3])
pg10 = gmsh.model.geo.addPhysicalGroup(1, [r3l4])
pg11 = gmsh.model.geo.addPhysicalGroup(1, [r3l5])

gmsh.model.setPhysicalName(2, pg1, "guide-tube")
gmsh.model.setPhysicalName(2, pg2, "fission-chamber")
gmsh.model.setPhysicalName(2, pg3, "UO2")
gmsh.model.setPhysicalName(2, pg4, "MOX_43")
gmsh.model.setPhysicalName(2, pg5, "MOX_7")
gmsh.model.setPhysicalName(2, pg6, "MOX_87")
gmsh.model.setPhysicalName(2, pg7, "water")
gmsh.model.setPhysicalName(1, pg8, "bottom")
gmsh.model.setPhysicalName(1, pg9, "right")
gmsh.model.setPhysicalName(1, pg10, "top")
gmsh.model.setPhysicalName(1, pg11, "left")

# removemos puntos de la geometria duplicados (origenes por ejemplo)
gmsh.model.geo.removeAllDuplicates()

gmsh.model.geo.synchronize()

gmsh.model.mesh.generate(2)

gmsh.write("c5g7-2.msh")

if !("-nopopup" in ARGS)
    gmsh.fltk.run()
end

gmsh.finalize()

# move to json file format
using Gridap
mshfile = joinpath(@__DIR__,"../c5g7-2.msh")
model = GmshDiscreteModel(mshfile; renumber=true)
Gridap.Io.to_json_file(model, "c5g7-2.json")


using MPI
using Oceananigans.Fields: location
import Oceananigans.Fields: Field
import Oceananigans.Grids: AbstractGrid, size

function Field(X, Y, Z, arch::AbstractMultiArchitecture, grid::AbstractGrid,
                bcs = FieldBoundaryConditions(grid, (X, Y, Z)),
               data = new_data(eltype(grid), arch, grid, (X, Y, Z)))

    communicative_bcs = inject_halo_communication_boundary_conditions(bcs, arch.local_rank, arch.connectivity)

    return Field(X, Y, Z, child_architecture(arch), grid, communicative_bcs, data)
end


using Test
using BinaryWrappers
import lib4ti2_jll

function run_4ti2(dir::String, tool::String, example::String; wrapper=false)
    mktempdir() do tmp
        cp(dir, joinpath(tmp, example))
        tmpexample = joinpath(tmp, example, example)
        # at least one output file should be named in the outputname file
        outfile = readchomp(joinpath(tmp, example, "outputname"))
        chomp(outfile)

        if wrapper
            @test success(pipeline(`$tool $tmpexample`,devnull, devnull))
        else
            jlltool = getfield(lib4ti2_jll,Symbol(tool))
            if typeof(jlltool) == String
               # shell script wont work like this
               @test_broken success(pipeline(`$(jlltool()) $tmpexample`, devnull, devnull))
               return nothing
            else
               @test success(pipeline(`$(jlltool()) $tmpexample`, devnull, devnull))
            end
        end
        return readchomp(joinpath(tmp, example, outfile))
    end
end

@testset "lib4ti2 examples" begin
    binpath = @generate_wrappers(lib4ti2_jll)
    ENV["PATH"] = binpath * ":" * ENV["PATH"]

    @test isfile(joinpath(binpath, "circuits"))
    @test isfile(joinpath(binpath, "graver"))
    @test isfile(joinpath(binpath, "groebner"))
    @test isfile(joinpath(binpath, "hilbert"))
    @test isfile(joinpath(binpath, "zsolve"))

    path = joinpath(@__DIR__, "examples", "4ti2")

    for tool in readdir(path)
        for example in readdir(joinpath(path, tool))
            dir = joinpath(path, tool, example)

            @testset "$tool: $example" begin
                jllout = run_4ti2(dir, tool, example)
                wrapperout = run_4ti2(dir, tool, example; wrapper=true)
                if jllout != nothing && wrapperout != nothing
                    @test jllout == wrapperout
                end
            end
        end
    end
end


"""
    FactorType

`fixed` `manipulated` - A crossed fixed-effects factor

`random` `measured` - A crossed random-effects factor

`nested` - A random factor fully nested within another factor

`subject` `block` - A random factor subjected to multiple levels of another factor
"""
@enum FactorType fixed manipulated random measured nested subject block

Broadcast.broadcastable(a::FactorType) = (a,) # workaround for current behavior

isnested(x) = x == nested
isfixed(x) = x == fixed || x == manipulated
israndom(x) = x == random || x == measured
issubject(x) = x == subject || x == block


function generate_rand_cache(N, M; T=Float64, range=-100.0:100.0)
    cache = DS.PointCache{T}()
    for _ in 1:M
        DS.CachePush(cache, rand(range,N), rand(range))
    end
    return cache
end

@testset "Cache" begin
    T = Float64
    c = DS.PointCache{T}()
    @testset "Constructor" begin
        @test typeof(c.costs) == Dict{Vector{T},T}
        @test length(c.costs) == 0
        @test typeof(c.order) == Vector{Vector{T}}
        @test length(c.order) == 0
    end

    @testset "CachePush" begin
        @test_throws MethodError DS.CachePush(c, [1,2,3], 1)
        @test_throws MethodError DS.CachePush(c, [1.0,2.0,3.0], 1)
        @test_throws MethodError DS.CachePush(c, [1,2,3], 1.0)

        DS.CachePush(c, [1.0,2.0,3.0], 4.0)
        @test c.costs[[1.0,2.0,3.0]] == 4.0

        DS.CachePush(c, [1.0,2.0,3.0], 6.0)
        @test c.costs[[1.0,2.0,3.0]] == 6.0

        DS.CachePush(c, [4.21, 45.0, 1234321.0], 2112.0)
        @test c.costs[[1.0,2.0,3.0]] == 6.0
        @test c.costs[[4.21, 45.0, 1234321.0]] == 2112.0
    end

    @testset "CacheOrderPush" begin
        @test c.order == []
        DS.CacheOrderPush(c, nothing)
        @test c.order == []
        DS.CacheOrderPush(c, [1.0, 2.0, 3.0])
        @test c.order == [[1.0, 2.0, 3.0]]
        DS.CacheOrderPush(c, [2.0, 2.0, 2.0])
        @test c.order == [[1.0, 2.0, 3.0], [2.0, 2.0, 2.0]]
        DS.CacheOrderPush(c, [2.0, 2.0, 2.0])
        @test c.order == [[1.0, 2.0, 3.0], [2.0, 2.0, 2.0]]
        DS.CacheOrderPush(c, [1.0, 2.0, 3.0])
        @test c.order == [[1.0, 2.0, 3.0], [2.0, 2.0, 2.0],[1.0, 2.0, 3.0]]
    end

    @testset "CacheQuery" begin
        @test_throws MethodError DS.CacheQuery(c, [1,2,3])

        @test DS.CacheQuery(c, [1.0,2.0,3.0])
        @test !DS.CacheQuery(c, [1.0,1.0,1.0])
    end

    @testset "CacheGet" begin
        @test_throws MethodError DS.CacheGet(c, [1,2,3])

        @test DS.CacheGet(c, [4.21, 45.0, 1234321.0]) == 2112.0
        @test DS.CacheGet(c, [1.0,2.0,3.0]) ==  6.0
        @test_throws KeyError DS.CacheGet(c, [1.0,2.0,4.0])
     end

    @testset "CacheRandomSample" begin
        @test DS.CacheRandomSample(c, 0) == []
        @test DS.CacheRandomSample(c, 1)[1] in c.order
        @test length(DS.CacheRandomSample(c, 1)) == 1
        @test all(p in c.order for p in DS.CacheRandomSample(c, 10))
        @test length(DS.CacheRandomSample(c, 2)) == 2
    end

    @testset "CacheInitialPoint" begin
        @test_throws BoundsError DS.CacheInitialPoint(DS.PointCache{T}())
        @test DS.CacheInitialPoint(c) == ([1.0,2.0,3.0], 6.0)
    end

    @testset "CacheGetRange" begin
        @test DS.CacheGetRange(c, [[4.21, 45.0, 1234321.0], [1.0,2.0,3.0]]) == [2112.0, 6.0]
        @test DS.CacheGetRange(c, []) == []
        @test_throws KeyError DS.CacheGetRange(c, [[4.21, 45.0, 1234321.0], [1.0,2.0,4.0]])

    end

    @testset "CacheFilter" begin
        @test DS.CacheFilter(c, [[4.21, 45.0, 1234321.0], [1.0,2.0,3.0], [7.0,8.0,9.0]]) ==
              ([[4.21, 45.0, 1234321.0], [1.0,2.0,3.0]], [[7.0,8.0,9.0]])
        @test DS.CacheFilter(c, [[4.21, 45.0, 1234321.0], [1.0,2.0,3.0]]) ==
              ([[4.21, 45.0, 1234321.0], [1.0,2.0,3.0]], [])
        @test DS.CacheFilter(c, [[4.22, 68.0, -1114754.0], [8.0,1.0,2.0]]) ==
              ([], [[4.22, 68.0, -1114754.0], [8.0,1.0,2.0]])
        @test DS.CacheFilter(c, []) == ([], [])

    end
end


struct ConstantLayer
    w
end

ConstantLayer(N::Integer) = ConstantLayer(zeros(N))

(m::ConstantLayer)(x) = m.w

Flux.@functor ConstantLayer

mutable struct A2TNetwork
  base::Chain
  attn::Chain
  solutions::Array{Any} # function takes in state and outputs vector of action values
  ignore_solution_computation::Bool
end

# By default, ignore the computation of the solutions
A2TNetwork(base, attn, solutions) = A2TNetwork(base, attn, solutions, true)

function (m::A2TNetwork)(input)
    b = m.base(input) #output is (Na, B)
    w = m.attn(input) #output is (Nt+1, B)
    Na, B, Nt  = size(b,1), size(b, 2), size(w,1) - 1

    if m.ignore_solution_computation
        qs = Array{Float32}(undef, Nt, Na, B)
        Zygote.ignore(() -> begin
            for i=1:B, s=1:Nt
                qs[s, :, i] .= m.solutions[s](input[:, i])
            end
        end)
    else
        qs = Flux.stack([m.solutions[s](input) for s in 1:Nt], 1)
    end
    sum(qs .* Flux.unsqueeze(w[1:Nt, :], 2), dims=1)[1,:,:] .+ w[Nt+1:Nt+1, :] .* b
end

Flux.@functor A2TNetwork

Flux.trainable(m::A2TNetwork) = (m.base, m.attn, (!m.ignore_solution_computation ? m.solutions : ())...)

function Base.iterate(m::A2TNetwork, i=1)
    layers = [m.base.layers..., m.attn.layers...]
    if !m.ignore_solution_computation
        for s in m.solutions
            for l in s
                push!(layers, l)
            end
        end
    end
    i > length(layers) && return nothing
    return (layers[i], i+1)
end

## A2T Network with state transform
mutable struct A2TSTNetwork
  base::Chain
  attn::Chain
  strans::Array{Chain}
  solutions::Array{Any} # function takes in state and outputs vector of action values
end

function (m::A2TSTNetwork)(input)
    b = m.base(input) #output is (Na, B)
    w = m.attn(input) #output is (Nt+1, B)
    Na, B, Nt  = size(b,1), size(b, 2), size(w,1) - 1
    qs = Zygote.Buffer(Array{Float32}(undef, Nt, Na, B))
    for s=1:Nt
        tinput = m.strans[s](input)
        for i=1:B
            qs[s, :, i] = m.solutions[s](tinput[:, i])
        end
    end
    sum(copy(qs) .* Flux.unsqueeze(w[1:Nt, :], 2), dims=1)[1,:,:] .+ w[Nt+1:Nt+1, :] .* b
end

Flux.@functor A2TSTNetwork

Flux.trainable(m::A2TSTNetwork) = (m.base, m.attn, m.strans...)

function Base.iterate(m::A2TSTNetwork, i=1)
    layers = [m.base.layers..., m.attn.layers...]
    for net in m.strans
        push!(layers, net.layers...)
    end
    i > length(layers) && return nothing
    return (layers[i], i+1)
end

## A2T network with fine tuning

mutable struct A2TFTNetwork
  base::Chain
  attn::Chain
  solutions::Array{Any} # function takes in state and outputs vector of action values
  finetune::Array{Chain} # fine tune network, one for each soluion
end

function (m::A2TFTNetwork)(input) # input is (Ni, B)
    Ni = size(input, 1)
    b = m.base(input) #output is (Na, B)
    w = m.attn(input) #output is (Nt+1, B)
    Na, B, Nt  = size(b,1), size(b, 2), size(w,1) - 1

    qs = Array{Float32}(undef, Nt, Na + Ni, B)
    Zygote.ignore(() -> begin
        for i=1:B, s=1:Nt
            qs[s, 1:Na, i] .= m.solutions[s](input[:, i])
            qs[s, Na+1:Na+Ni, i] .= input[:, i]
        end
    end)

    ft = Zygote.Buffer(Array{Float32}(undef, Nt, Na, B))
    for s=1:Nt
        ft[s, :, :] = m.finetune[s](qs[s, :, :])
    end

    sum(copy(ft) .* Flux.unsqueeze(w[1:Nt, :], 2), dims=1)[1,:,:] .+ w[Nt+1:Nt+1, :] .* b
end

Flux.@functor A2TFTNetwork

Flux.trainable(m::A2TFTNetwork) = (m.base, m.attn, m.finetune...)

function Base.iterate(m::A2TFTNetwork, i=1)
    layers = [m.base.layers..., m.attn.layers...]
    for net in m.finetune
        push!(layers, net.layers...)
    end
    i > length(layers) && return nothing
    return (layers[i], i+1)
end

## A Fine-tune network with choice of number of layers to freeze

mutable struct FTNetwork
  net::Chain
  train_layers::Array{Int}
end

(m::FTNetwork)(input) = m.net(input)
Flux.@functor FTNetwork

Flux.trainable(m::FTNetwork) = m.net.layers[m.train_layers]

function Base.iterate(m::FTNetwork, i=1)
    i > length(m.train_layers) && return nothing
    return (m.net.layers[m.train_layers[i]], i+1)
end


export
IndexedBinaryTree,
itree,
index,
ileaves,
itraversewhen,
ileft,
iright

const EmptyIndexedNode = -1

struct IndexedNode{T}
  left::Int
  right::Int
  next::Int # next can mean parent or next free node
  data::T
  IndexedNode{T}() where T = new{T}(EmptyIndexedNode, EmptyIndexedNode, EmptyIndexedNode)
  IndexedNode{T}(l,r) where T = new{T}(l, r, EmptyIndexedNode)
  IndexedNode{T}(l,r,n) where T = new{T}(l, r, n)
  IndexedNode{T}(l,r,n,d::T) where T = new{T}(l, r, n, d)
end

IndexedNode(a::T) where T = IndexedNode{T}(EmptyIndexedNode, EmptyIndexedNode, EmptyIndexedNode, a)
IndexedNode(l,r,n,d::T) where T = IndexedNode{T}(l, r, n, d)

mutable struct NodeVector{T}
  nodes::Vector{IndexedNode{T}}
  root::Int
  nextfree::Int
  last::Int
  #nextfreeleaf::Int  # can optionally reserve first block of indexes for leaves.  Enables direct indexing
end

isleaf(n::IndexedNode{T}) where T = EmptyIndexedNode == n.left &&
                                    EmptyIndexedNode == n.right

parent(n::IndexedNode{T}) where T = n.next
setparent(n::IndexedNode{T}, iparent) where T = IndexedNode{T}(n.left, n.right, iparent, n.data)
setnext(n::IndexedNode{T}, inext) where T = setparent(n, inext)

Base.length(nv::NodeVector{T}) where T = length(nv.nodes)

struct IndexedBinaryTree{T} <: BinaryTree{T}
  nodes::NodeVector{T}
  node::Int
end

IndexedBinaryTree{T}(d::T, ::Nothing, ::Nothing) where T = itree(d)
IndexedBinaryTree(d::T) where T = itree(d)
IndexedBinaryTree{T}(d::T) where T = itree(d)

isroot(t::IndexedBinaryTree{T}) where T = t.nodes.root == t.node

function NodeVector{T}(sz::Int) where T
  nodes = [IndexedNode{T}(EmptyIndexedNode, EmptyIndexedNode, i) for i in vcat(2:sz, EmptyIndexedNode)]
  NodeVector{T}(nodes, EmptyIndexedNode, 1, sz)
end

index(tree::IndexedBinaryTree{T}) where T = tree.node
node(tree::IndexedBinaryTree{T}) where T = tree.nodes[tree.node]

root(tree::IndexedBinaryTree{T}) where T = IndexedBinaryTree{T}(tree.nodes, tree.nodes.root)

Base.getindex(A::IndexedBinaryTree{T}, i::Number) where {T} = IndexedBinaryTree{T}(A.nodes, i)

function ileaves(t::IndexedBinaryTree{T}) where T
  L = Vector{IndexedNode{T}}(undef, 16)
  resize!(L, 0);
  ileaves(t.nodes, t.node, L);
  return L
end

function ileaves( n::NodeVector{T}, i::Int, L::Vector{IndexedNode{T}} ) where T
  nc = 0
  if n.nodes[i].left != EmptyIndexedNode
    nc = 1
    ileaves(n, n.nodes[i].left, L)
  end
  if n.nodes[i].right != EmptyIndexedNode
    nc += 1
    ileaves(n, n.nodes[i].right, L)
  end

  if nc == 0
    push!(L, n.nodes[i])
  end
end

function itraversewhen(when::Function, node::IndexedBinaryTree{T}, L::Vector{R},
                       pre::Union{Function, Nothing},
                       post::Union{Function, Nothing},
                       inorder::Union{Function, Nothing}) where {T, R}
  if !when(node)
    return
  end

  if pre != nothing
    push!(L, pre(node))
  end

  node.nodes[node.node].left != EmptyIndexedNode && itraversewhen(when, left(node), L, pre, post, inorder)

  if inorder != nothing
    push!(L, inorder(node))
  end

  node.nodes[node.node].right != EmptyIndexedNode && itraversewhen(when, right(node), L, pre, post, inorder)
  if post != nothing
    push!(L, post(node))
  end
end

function itraversewhen(when::Function, t::IndexedBinaryTree{T}, returnType::Type,
                       pre, post, inorder = nothing) where T
  L = Vector{returnType}(undef, length(t.nodes))
  resize!(L, 0);
  itraversewhen(when, t, L, pre, post, inorder);
  return L
end

function itraversewhen(when::Function, node::IndexedBinaryTree{T}, L::Vector{R},
                       pre::Function) where {T, R}
  if !when(node)
    return
  end

  push!(L, pre(node))

  node.nodes[node.node].left != EmptyIndexedNode && itraversewhen(when, left(node), L, pre)
  node.nodes[node.node].right != EmptyIndexedNode && itraversewhen(when, right(node), L, pre)
end

function itraversewhen(when::Function, t::IndexedBinaryTree{T}, returnType::Type,
                       pre::Function ) where T
  L = Vector{returnType}(undef, length(t.nodes))
  resize!(L, 0);
  itraversewhen(when, t, L, pre);
  return L
end

function resize( nv::NodeVector{T}, sz::Int ) where T
  oldsz = length(nv.nodes)
  if sz < oldsz
    return nv
  end

  if oldsz*2 > sz
    sz = oldsz*2
  end
  newnodes = [IndexedNode{T}(EmptyIndexedNode, EmptyIndexedNode, i)
              for i in vcat((oldsz+2):sz, EmptyIndexedNode)]
  nodes = vcat(nv.nodes, newnodes)
  lastn = nodes[nv.last]
  nodes[nv.last] = IndexedNode{T}(lastn.left, lastn.right, oldsz+1, lastn.data)
  NodeVector{T}(nodes, nv.root, oldsz+1, sz)
end

import Base.resize!
function resize!( nv::NodeVector{T}, sz::Int ) where T
  oldsz = length(nv.nodes)
  if sz < oldsz
    return nv
  end

  if oldsz*2 > sz
    sz = oldsz*2
  end
  resize!(nv.nodes, sz)
  newnodes = [IndexedNode{T}(EmptyIndexedNode, EmptyIndexedNode, i)
              for i in vcat((oldsz+2):sz, EmptyIndexedNode)]
  nv.nodes[oldsz+1:sz] = newnodes
  lastn = nv.nodes[nv.last]
  nv.nodes[nv.last] = IndexedNode{T}(lastn.left, lastn.right, oldsz+1, lastn.data)

  if nv.nextfree == EmptyIndexedNode
    nv.nextfree = oldsz+1
  end

  nv.last = sz
  return nv
end

"""
    reserve_leaves!(nv, n)

For internal use.
reserve the next n nodes for allocating to leave in order of insertion

this allows direct indexing of leaves with implicit in-order indices
uses any exising free nodes, so if those aren't ordered then this will not be true
"""
#==
function reserve_leaves!( nv::NodeVector{T}, n:Int ) where T
  nv.nextfreeleaf = nv.last
  oldsz = length(nv.nodes)
  resize!(nv, n+oldsz+1)
  nv.nextfree = n
  nv.nodes[nv.nextfreeleaf+n-1].next = EmptyIndexedNode

end
==#

Base.size(A::NodeVector{T}) where T = size(A.nodes)
Base.getindex(A::NodeVector{T}, i::Number) where {T} = A.nodes[i]
Base.setindex!(A::NodeVector{T}, n::IndexedNode{T}, i::Number) where {T} = A.nodes[i] = n
Base.firstindex(A::NodeVector{T}) where T = A.root

itree(ibt::IndexedBinaryTree{T}, node::Int) where T = IndexedBinaryTree{T}(ibt.nodes, node)
itree(a::T) where T = IndexedBinaryTree{T}(NodeVector{T}([IndexedNode(a)], 1, EmptyIndexedNode, 1), 1)
itree(dc::V) where {T, V<:AbstractVector{T}} = reduce(insert, Iterators.drop(dc,1); init = itree(first(dc)))

key(tree::IndexedBinaryTree{T}) where T = key(tree.nodes[tree.node].data)

left(tree::IndexedBinaryTree{T}) where T = isempty_node(tree.nodes[tree.node].left) ? nothing : itree(tree, tree.nodes[tree.node].left)

right(tree::IndexedBinaryTree{T}) where T = isempty_node(tree.nodes[tree.node].right) ? nothing : itree(tree, tree.nodes[tree.node].right)

ileft(tree::IndexedBinaryTree{T}) where T = tree.nodes[tree.node].left
iright(tree::IndexedBinaryTree{T}) where T = tree.nodes[tree.node].right

parent(tree::IndexedBinaryTree{T}) where T = isempty_node(tree.nodes[tree.node].parent) ? nothing : itree(tree, parent(tree.nodes[tree.node]))

isempty_node(tree::IndexedBinaryTree{T}) where T = tree.node == EmptyIndexedNode
isempty_node(i::Int) = i == EmptyIndexedNode

data(tree::IndexedBinaryTree{T}) where T = tree.nodes[tree.node].data
keyval(tree::IndexedBinaryTree{T}) where T = data(tree)
data(n::IndexedNode{T}) where T = n.data
keyval(n::IndexedNode{T}) where T = n.data

function search(stree::IndexedBinaryTree{T}, k::T) where T
  hit = nothing
  while true
    if k == key(stree)
      hit = (stree, :found)
      break
    elseif k < key(stree)
      if isnothing(left(stree))
        hit = (stree, :left)
        break
      else
        stree = left(stree)
      end
    else
      if isnothing(right(stree))
        hit = (stree, :right)
        break
      else
        stree = right(stree)
      end
    end
  end
  return hit
end

#==
function insert(::Type{NotFullTree}, t::IndexedBinaryTree{T}, d::T) where T
  nv = t.nodes
  if t.nodes.nextfree == EmptyIndexedNode
    nv = resize(nv, length(nv)+1)
  end
  inode = nv.nextfree
  nv_nextfree = nv[inode].next

  hit = search(t, d)
  hit = isnothing(hit) ? (t, :found) : hit

  iparent = first(hit).node
  p = nv[iparent]
  if hit[2] == :left
    p = IndexedNode{T}(inode, p.right, parent(p), p.data)
  else
    p = IndexedNode{T}(p.left, inode, parent(p), p.data)
  end

  cpnodes = copy(nv.nodes)
  cpnodes[inode] = IndexedNode{T}(EmptyIndexedNode, EmptyIndexedNode, iparent, d)
  cpnodes[iparent] =  p
  IndexedBinaryTree{T}(NodeVector{T}(cpnodes, nv.root, nv_nextfree, nv.last), nv.root)
end

import Base.insert!

insert!( t::N, d::T ) where {T, N<:BinaryTree{T}} = insert!( fullness(N), t, d )

function insert!(::Type{NotFullTree}, tree::IndexedBinaryTree{T}, d::T) where T
  nv = tree.nodes
  if tree.nodes.nextfree == EmptyIndexedNode
    nv = resize!(nv, length(nv)+1)
  end
  inode = nv.nextfree
  nv_nextfree = nv[inode].next

  hit = search(tree, d);
  hit = isnothing(hit) ? (tree, :found) : hit

  iparent = first(hit).node
  p = nv[iparent]
  if hit[2] == :left
    p = IndexedNode{T}(inode, p.right, parent(p), p.data)
  else
    p = IndexedNode{T}(p.left, inode, parent(p), p.data)
  end

  cpnodes = nv.nodes
  cpnodes[inode] = IndexedNode{T}(EmptyIndexedNode, EmptyIndexedNode, iparent, d)
  cpnodes[iparent] =  p
  IndexedBinaryTree{T}(NodeVector{T}(cpnodes, nv.root, nv_nextfree, nv.last), nv.root)
end
==#

function rebuild_node!(dir::Union{Val{:both}, Val{:left}, Val{:right}},
             lc::Branch{T}, rc::Branch{T},
             n::IndexedBinaryTree{T}, d::T ) where T

  nv = n.nodes
  inode = n.node
  if isnothing(lc)
    ilc = EmptyIndexedNode
  elseif dir != Val{:right}() && isleaf(lc)
    # copy leaf into parents vector
    if nv.nextfree == EmptyIndexedNode
      nv = resize!(nv, length(nv)+2)  # adding two for case of 2 children
    end

    #==
    #!me possible orphaning? then we need something like this, but then that should looks like
    #!me else case after this
    #!me can happen for full trees (:both) or rotations we move a node from this tree to this tree?
    if !isroot(lc)
      nv[index(lc)] = setnext(node(lc), nv.nextfree)
      nv.nextfree = index(lc)
    end
    ==#

    ilc = nv.nextfree   # if not new index it will orphan current lc. else nv[index(lc)] = setparent(node(lc), nv.nextfree);  nv.nextfree = index(lc);  # maybe put in free() method
    nv.nextfree = nv[ilc].next
    nv[ilc] = setparent(node(lc), inode)
  else
    # child and parents vector should be the same
    ilc = index(lc)
    nv[ilc] = setparent(node(lc), inode)
  end

  if isnothing(rc)
    irc = EmptyIndexedNode
  elseif dir != Val{:left}() && isleaf(rc)
    # copy leaf into parents vector
    if nv.nextfree == EmptyIndexedNode
      nv = resize!(nv, length(nv)+1)
    end
    irc = nv.nextfree
    nv.nextfree = nv[irc].next
    nv[irc] = setparent(node(rc), inode)
  else
    # child and parents vector should be the same
    irc = index(rc)
    nv[irc] = setparent(node(rc), inode)
  end

  imabastard = EmptyIndexedNode
  tn = IndexedNode{T}(ilc, irc, imabastard, d)
  nv[inode] = tn
  IndexedBinaryTree{T}(nv, inode)
end

rebuild_node(dir::Union{Val{:both}, Val{:left}, Val{:right}},
             lc::Branch{T}, rc::Branch{T},
             n::IndexedBinaryTree{T}, d::T ) where T =
  rebuild_node!(dir, lc, rc, n, d)


n_features = (32, 32, 3)
n_targets = (coarse=1, fine=1)

@testset "trainset" begin
    d = CIFAR100()

    @test d.split == :train
    @test extrema(d.features) == (0, 1)
    @test convert2image(d, 1) isa AbstractMatrix{<:RGB}
    @test convert2image(d, 1:2) isa AbstractArray{<:RGB,3}

    test_supervised_array_dataset(d;
        n_features, n_targets, n_obs=50000,
        Tx=Float32, Ty=Int)
end

@testset "testset" begin
    d = CIFAR100(split=:test, Tx=UInt8)

    @test d.split == :test
    @test extrema(d.features) == (0, 255)
    @test convert2image(d, 1) isa AbstractMatrix{<:RGB}
    @test convert2image(d, 1:2) isa AbstractArray{<:RGB,3}

    test_supervised_array_dataset(d;
        n_features, n_targets, n_obs=10000,
        Tx=UInt8, Ty=Int)
end


module ROCKET

using Distributions
using CoordinateDescent, ProximalBase

export
  teInference

include("utils.jl")
include("inference.jl")

end


module AccuracyBenchmark

using DataFrames
using Distributions
import FITSIO
import StaticArrays
import WCS

import ..Config
import ..DeterministicVI
import ..Infer
import ..Model
import ..ParallelRun
import ..SDSSIO
using ..Coordinates: angular_separation, match_coordinates

const ARCSEC_PER_DEGREE = 3600
const SDSS_ARCSEC_PER_PIXEL = 0.396
const SDSS_DATA_DIR = joinpath(Pkg.dir("Celeste"), "test", "data")
const STRIPE82_RCF = SDSSIO.RunCamcolField(4263, 5, 119)

struct BenchmarkFitsFileNotFound <: Exception
    filename::String
end

struct MatchException <: Exception
    msg::String
end

struct MissingColumnsError <: Exception
    missing_columns::Vector{Symbol}
end

################################################################################
# Load/store a catalog DF from/to a CSV.
################################################################################

CATALOG_COLUMNS = Set([
    :right_ascension_deg,
    :declination_deg,
    :is_star,
    :reference_band_flux_nmgy,
    :color_log_ratio_ug,
    :color_log_ratio_gr,
    :color_log_ratio_ri,
    :color_log_ratio_iz,
    :de_vaucouleurs_mixture_weight,
    :minor_major_axis_ratio,
    :half_light_radius_px,
    :angle_deg,
    :is_saturated,
])

STDERR_COLUMNS = Set([
    :log_reference_band_flux_stderr,
    :color_log_ratio_ug_stderr,
    :color_log_ratio_gr_stderr,
    :color_log_ratio_ri_stderr,
    :color_log_ratio_iz_stderr,
])

function assert_columns_are_present(catalog_df::DataFrame, required_columns::Set{Symbol})
    missing_columns = setdiff(required_columns, Set(names(catalog_df)))
    if !isempty(missing_columns)
        throw(MissingColumnsError([missing_columns...]))
    end
end

function read_catalog(csv_file::String)
    @printf("Reading '%s'...\n", csv_file)
    catalog_df = readtable(csv_file)
    assert_columns_are_present(catalog_df, CATALOG_COLUMNS)
    catalog_df
end

function write_catalog(csv_file::String, catalog_df::DataFrame)
    assert_columns_are_present(catalog_df, CATALOG_COLUMNS)
    @printf("Writing '%s'...\n", csv_file)
    writetable(csv_file, catalog_df)
end

function append_hash_to_file(filename::String)
    contents_hash = open(filename) do stream
        hash(read(stream))
    end
    hash_string = hex(contents_hash)[1:10]
    base, extension = splitext(filename)
    new_filename = @sprintf("%s_%s%s", base, hash_string, extension)
    @printf("Renaming %s -> %s\n", filename, new_filename)
    mv(filename, new_filename, remove_destination=true)
end

################################################################################
# Read various catalogs to a common catalog DF format
################################################################################

"""
Convert between SDSS asinh mags and SDSS flux (nMgy).
See http://www.sdss.org/dr12/algorithms/magnitudes/#asinh.
"""
const ASINH_SOFTENING_PARAMETERS = [
    1.4e-10, # for band 1 = u
    0.9e-10,
    1.2e-10,
    1.8e-10,
    7.4e-10, # for band 5 = z
]

function mag_to_flux(mags::AbstractFloat, band_index::Int)
    b = ASINH_SOFTENING_PARAMETERS[band_index]
    1e9 * 2 * b * sinh(-log(10) / 2.5 * mags - log(b))
end

function flux_to_mag(flux_nmgy::AbstractFloat, band_index::Int)
    b = ASINH_SOFTENING_PARAMETERS[band_index]
    -2.5 / log(10) * (asinh(flux_nmgy * 1e-9 / (2*b)) + log(b))
end

function color_from_fluxes(flux1::AbstractFloat, flux2::AbstractFloat)
    if flux1 <= 0 || flux2 <= 0
        NA
    else
        log(flux2 / flux1)
    end
end

function color_from_mags(mags1::AbstractFloat, band1::Int, mags2::AbstractFloat, band2::Int)
    color_from_fluxes(mag_to_flux(mags1, band1), mag_to_flux(mags2, band2))
end

canonical_angle(angle_deg) = angle_deg - floor(angle_deg / 180) * 180

STRIPE_82_CATALOG_KEYS = [
    :objid, :rerun, :run, :camcol, :field, :flags,
    :ra, :dec, :probpsf,
    :psfmag_u, :psfmag_g, :psfmag_r, :psfmag_i, :psfmag_z,
    :devmag_u, :devmag_g, :devmag_r, :devmag_i, :devmag_z,
    :expmag_u, :expmag_g, :expmag_r, :expmag_i, :expmag_z,
    :fracdev_r,
    :devab_r, :expab_r,
    :devphi_r, :expphi_r,
    :devrad_r, :exprad_r,
    :flags, :is_saturated,
]

function load_fits_table_as_data_frame(filename, extension_index, field_names)
    result_df = DataFrame()
    fits = FITSIO.FITS(filename)
    try
        for key in field_names
            result_df[key] = read(fits[extension_index], string(key))
        end
        return result_df
    finally
        close(fits)
    end
end

function load_stripe82_fits_catalog_as_data_frame(filename, extension_index)
    load_fits_table_as_data_frame(filename, extension_index, STRIPE_82_CATALOG_KEYS)
end

"""
Load Stripe 82 objects into a DataFrame. `fits_filename` should be a FITS file created by running a
CasJobs (skyserver.sdss.org/casjobs/) query on the Stripe82 database. See
https://github.com/jeff-regier/Celeste.jl/wiki/About-SDSS-and-Stripe-82.
"""
function load_coadd_catalog(fits_filename)
    raw_df = load_stripe82_fits_catalog_as_data_frame(fits_filename, 2)

    usedev = raw_df[:fracdev_r] .> 0.5  # true=> use dev, false=> use exp
    dev_or_exp(dev_column, exp_column) = ifelse.(usedev, raw_df[dev_column], raw_df[exp_column])
    is_star = [x != 0 for x in raw_df[:probpsf]]
    function star_or_galaxy(star_column, galaxy_dev_column, galaxy_exp_column)
        ifelse.(is_star, raw_df[star_column], dev_or_exp(galaxy_dev_column, galaxy_exp_column))
    end

    mag_u = star_or_galaxy(:psfmag_u, :devmag_u, :expmag_u)
    mag_g = star_or_galaxy(:psfmag_g, :devmag_g, :expmag_g)
    mag_r = star_or_galaxy(:psfmag_r, :devmag_r, :expmag_r)
    mag_i = star_or_galaxy(:psfmag_i, :devmag_i, :expmag_i)
    mag_z = star_or_galaxy(:psfmag_z, :devmag_z, :expmag_z)

    result = DataFrame()
    result[:objid] = [string(objid) for objid in raw_df[:objid]]
    result[:right_ascension_deg] = raw_df[:ra]
    result[:declination_deg] = raw_df[:dec]
    result[:is_saturated] = (raw_df[:is_saturated] .!= 0)
    result[:is_star] = is_star

    result[:reference_band_flux_nmgy] = mag_to_flux.(mag_r, 3)

    result[:color_log_ratio_ug] = color_from_mags.(mag_u, 1, mag_g, 2)
    result[:color_log_ratio_gr] = color_from_mags.(mag_g, 2, mag_r, 3)
    result[:color_log_ratio_ri] = color_from_mags.(mag_r, 3, mag_i, 4)
    result[:color_log_ratio_iz] = color_from_mags.(mag_i, 4, mag_z, 5)

    # gal shape -- fracdev
    result[:de_vaucouleurs_mixture_weight] = raw_df[:fracdev_r]

    # Note that the SDSS photo pipeline doesn't constrain the de Vaucouleur
    # profile parameters and exponential disk parameters (A/B, angle, scale)
    # to be the same, whereas Celeste does. Here, we pick one or the other
    # from SDSS, based on fracdev - we'll get the parameters corresponding
    # to the dominant component. Later, we limit comparison to objects with
    # fracdev close to 0 or 1 to ensure that we're comparing apples to apples.

    result[:minor_major_axis_ratio] = dev_or_exp(:devab_r, :expab_r)

    # gal effective radius (re)
    re_arcsec = dev_or_exp(:devrad_r, :exprad_r)
    re_pixel = re_arcsec ./ SDSS_ARCSEC_PER_PIXEL
    result[:half_light_radius_px] = convert(Vector{Float64}, re_pixel)

    # gal angle (degrees)
    raw_phi = dev_or_exp(:devphi_r, :expphi_r)
    result[:angle_deg] = canonical_angle.(raw_phi)

    return result
end

function object_dict_to_data_frame(objects::Dict)
    result_df = DataFrame()
    for (key, values) in objects
        result_df[Symbol(key)] = values
    end
    result_df
end

"""
Load the SDSS photoObj catalog used to initialize celeste, and reformat column
names to match what the rest of the scoring code expects.
"""
function load_primary(rcf::SDSSIO.RunCamcolField, stagedir::String)
    strategy = SDSSIO.PlainFITSStrategy(stagedir)
    raw_df = object_dict_to_data_frame(SDSSIO.read_photoobj(strategy, rcf))

    usedev = raw_df[:frac_dev] .> 0.5  # true=> use dev, false=> use exp
    dev_or_exp(dev_column, exp_column) = ifelse.(usedev, raw_df[dev_column], raw_df[exp_column])
    function star_or_galaxy(star_column, galaxy_dev_column, galaxy_exp_column)
        ifelse.(raw_df[:is_star], raw_df[star_column], dev_or_exp(galaxy_dev_column, galaxy_exp_column))
    end

    flux_u = star_or_galaxy(:psfflux_u, :devflux_u, :expflux_u)
    flux_g = star_or_galaxy(:psfflux_g, :devflux_g, :expflux_g)
    flux_r = star_or_galaxy(:psfflux_r, :devflux_r, :expflux_r)
    flux_i = star_or_galaxy(:psfflux_i, :devflux_i, :expflux_i)
    flux_z = star_or_galaxy(:psfflux_z, :devflux_z, :expflux_z)

    result = DataFrame()
    result[:objid] = raw_df[:objid]
    result[:right_ascension_deg] = raw_df[:ra]
    result[:declination_deg] = raw_df[:dec]
    result[:is_star] = raw_df[:is_star]

    result[:reference_band_flux_nmgy] = flux_r

    result[:color_log_ratio_ug] = color_from_fluxes.(flux_u, flux_g)
    result[:color_log_ratio_gr] = color_from_fluxes.(flux_g, flux_r)
    result[:color_log_ratio_ri] = color_from_fluxes.(flux_r, flux_i)
    result[:color_log_ratio_iz] = color_from_fluxes.(flux_i, flux_z)

    result[:de_vaucouleurs_mixture_weight] = raw_df[:frac_dev]
    result[:minor_major_axis_ratio] = dev_or_exp(:ab_dev, :ab_exp)

    # gal effective radius (re)
    re_arcsec = dev_or_exp(:theta_dev, :theta_exp)
    re_pixel = re_arcsec ./ SDSS_ARCSEC_PER_PIXEL
    result[:half_light_radius_px] = convert(Vector{Float64}, re_pixel)

    # gal angle (degrees)
    raw_phi = dev_or_exp(:phi_dev, :phi_exp)
    result[:angle_deg] = canonical_angle.(raw_phi)

    # primary is better at flagging oversaturated sources than coadd
    result[:is_saturated] = flux_to_mag.(raw_df[:psfflux_r], 3) .< 16

    return result
end

function fluxes_from_colors(reference_band_flux_nmgy::Float64, color_log_ratios::DataVector{Float64})
    @assert length(color_log_ratios) == 4
    color_ratios = exp.(color_log_ratios)
    fluxes = DataArray(Float64, 5)
    fluxes[3] = reference_band_flux_nmgy
    fluxes[4] = fluxes[3] * color_ratios[3]
    fluxes[5] = fluxes[4] * color_ratios[4]
    fluxes[2] = fluxes[3] / color_ratios[2]
    fluxes[1] = fluxes[2] / color_ratios[1]
    fluxes
end

function fluxes_from_colors(reference_band_flux_nmgy::Float64, color_log_ratios::Vector{Float64})
    fluxes = fluxes_from_colors(reference_band_flux_nmgy, DataArray{Float64}(color_log_ratios))
    convert(Vector{Float64}, fluxes)
end

function get_median_fluxes(variational_params::Vector{Float64}, source_type::Int64)
    fluxes_from_colors(
        exp(variational_params[Model.ids.flux_loc[source_type]]),
        variational_params[Model.ids.color_mean[:, source_type]],
    )
end

function variational_parameters_to_data_frame_row(variational_params::Vector{Float64})
    ids = Model.ids
    result = DataFrame()
    result[:right_ascension_deg] = variational_params[ids.pos[1]]
    result[:declination_deg] = variational_params[ids.pos[2]]
    result[:is_saturated] = false
    result[:is_star] = variational_params[ids.is_star[1, 1]]
    result[:de_vaucouleurs_mixture_weight] = variational_params[ids.gal_fracdev]
    result[:minor_major_axis_ratio] = variational_params[ids.gal_ab]
    result[:half_light_radius_px] = (
        variational_params[ids.gal_scale] * sqrt(variational_params[ids.gal_ab])
    )
    result[:angle_deg] = canonical_angle(180 / pi * variational_params[ids.gal_angle])

    star_galaxy_index = (result[1, :is_star] > 0.5 ? 1 : 2)
    fluxes = get_median_fluxes(variational_params, star_galaxy_index)
    result[:reference_band_flux_nmgy] = fluxes[3]
    result[:color_log_ratio_ug] = color_from_fluxes(fluxes[1], fluxes[2])
    result[:color_log_ratio_gr] = color_from_fluxes(fluxes[2], fluxes[3])
    result[:color_log_ratio_ri] = color_from_fluxes(fluxes[3], fluxes[4])
    result[:color_log_ratio_iz] = color_from_fluxes(fluxes[4], fluxes[5])

    result[:log_reference_band_flux_stderr] = sqrt(variational_params[ids.flux_scale[star_galaxy_index]])
    result[:color_log_ratio_ug_stderr] = sqrt(variational_params[ids.color_var[1, star_galaxy_index]])
    result[:color_log_ratio_gr_stderr] = sqrt(variational_params[ids.color_var[2, star_galaxy_index]])
    result[:color_log_ratio_ri_stderr] = sqrt(variational_params[ids.color_var[3, star_galaxy_index]])
    result[:color_log_ratio_iz_stderr] = sqrt(variational_params[ids.color_var[4, star_galaxy_index]])

    result
end

"""
Convert Celeste results to a dataframe.
"""
function celeste_to_df(results::Vector{ParallelRun.OptimizedSource})
    rows = [variational_parameters_to_data_frame_row(result.vs) for result in results]
    vcat(rows...)
end

################################################################################
# Generate a random catalog from the Celeste prior
################################################################################

# This is the ratio of stars derived from the catalog used the generate the prior; the value 0.99 is
# currently used in inference to account for the extra flexibility of the galaxy model
const PRIOR_PROBABILITY_OF_STAR = 0.28

"""
Draw a random source from the Celeste prior, returning a 1-row data frame.
"""
function draw_source_params(prior)
    is_star = (rand(Bernoulli(PRIOR_PROBABILITY_OF_STAR)) == 1)
    source_type_index = is_star ? 1 : 2
    reference_band_flux_nmgy = exp(rand(
        Normal(prior.flux_mean[source_type_index], sqrt(prior.flux_var[source_type_index]))
    ))

    color_mixture_weights = prior.k[:, source_type_index]
    NUM_COLOR_COMPONENTS = length(color_mixture_weights)
    color_components = MvNormal[
        MvNormal(prior.color_mean[:, k, source_type_index], prior.color_cov[:, :, k, source_type_index])
        for k in 1:NUM_COLOR_COMPONENTS
    ]
    color_log_ratios = rand(MixtureModel(color_components, color_mixture_weights))

    if !is_star
        half_light_radius_px = exp(rand(
            Normal(prior.gal_scale_mean, sqrt(prior.gal_scale_var))
        ))
        angle_deg = rand(Uniform(0, 180))
        minor_major_axis_ratio = rand(Beta(2, 2))
        de_vaucouleurs_mixture_weight = rand(Beta(0.5, 0.5))
    else
        half_light_radius_px = -1
        angle_deg = -1
        minor_major_axis_ratio = -1
        de_vaucouleurs_mixture_weight = -1
    end

    # Use approximate size of SDSS field in degrees
    right_ascension_deg = rand(Uniform(0, 0.14))
    declination_deg = rand(Uniform(0, 0.22))

    DataFrame(
        right_ascension_deg=right_ascension_deg,
        declination_deg=declination_deg,
        is_star=is_star,
        reference_band_flux_nmgy=reference_band_flux_nmgy,
        color_log_ratio_ug=color_log_ratios[1],
        color_log_ratio_gr=color_log_ratios[2],
        color_log_ratio_ri=color_log_ratios[3],
        color_log_ratio_iz=color_log_ratios[4],
        de_vaucouleurs_mixture_weight=de_vaucouleurs_mixture_weight,
        minor_major_axis_ratio=minor_major_axis_ratio,
        half_light_radius_px=half_light_radius_px,
        angle_deg=angle_deg,
        is_saturated=false,
    )
end

"""
Draw sources at random from Celeste prior, returning a catalog DF.
"""
function generate_catalog_from_celeste_prior(num_sources::Int64, seed::Int64)
    srand(seed)
    prior = Model.load_prior()
    vcat(
        [draw_source_params(prior) for index in 1:num_sources]...
    )
end

################################################################################
# Support for running Celeste on test imagery
################################################################################

## Load a multi-extension FITS imagery file

struct FitsImage
    pixels::Matrix{Float32}
    header::FITSIO.FITSHeader
    wcs::WCS.WCSTransform
end

function read_fits(filename::String)
    println("Reading '$filename'...")
    if !isfile(filename)
        throw(BenchmarkFitsFileNotFound(filename))
    end

    fits = FITSIO.FITS(filename)
    try
        println("Found $(length(fits)) extensions.")
        map(fits) do extension
            pixels = read(extension)
            header = FITSIO.read_header(extension)
            wcs = WCS.from_header(FITSIO.read_header(fits[1], String))[1]
            FitsImage(pixels, header, wcs)
        end
    finally
        close(fits)
    end
end

## Create Celeste images from FITS imagery

function make_simple_psf(psf_sigma_px)
    alphaBar = [1.; 0.]
    xiBar = [0.; 0.]
    tauBar = [psf_sigma_px^2 0.; 0. psf_sigma_px^2]
    [
        Model.PsfComponent(
            alphaBar[k],
            StaticArrays.SVector{2, Float64}(xiBar),
            StaticArrays.SMatrix{2, 2, Float64, 4}(tauBar)
        )
        for k in 1:2
    ]
end

function make_psf_from_header(header::FITSIO.FITSHeader)
    if haskey(header, "CLSIGMA")
        return make_simple_psf(header["CLSIGMA"])
    end

    num_components = header["PSFNCOMP"]
    map(1:num_components) do index
        get(field) = header["PSF$(index)_$(field)"]
        alpha_bar = get("A")
        xi_bar = Float64[get("X1"), get("X2")]
        tau_bar = Float64[get("T11") get("T12"); get("T12") get("T22")]
        Model.PsfComponent(
            alpha_bar,
            StaticArrays.SVector{2, Float64}(xi_bar),
            StaticArrays.SMatrix{2, 2, Float64, 4}(tau_bar),
        )
    end
end

function serialize_psf_to_header(psf::Vector{Model.PsfComponent}, header::FITSIO.FITSHeader)
    header["PSFNCOMP"] = length(psf)
    for (index, component) in enumerate(psf)
        @assert component.tauBar[1, 2] == component.tauBar[2, 1]
        function set(field, value)
            header["PSF$(index)_$(field)"] = value
        end
        set("A", component.alphaBar)
        set("X1", component.xiBar[1])
        set("X2", component.xiBar[2])
        set("T11", component.tauBar[1, 1])
        set("T12", component.tauBar[1, 2])
        set("T22", component.tauBar[2, 2])
    end
end

function make_image(
    pixels::Matrix{Float32}, band_index::Int, wcs::WCS.WCSTransform, psf::Vector{Model.PsfComponent},
    sky_level_nmgy::Float64, nelec_per_nmgy::Float64
)
    height_px, width_px = size(pixels)
    sky_intensity = Model.SkyIntensity(
        fill(sky_level_nmgy, height_px, width_px),
        collect(1:height_px),
        collect(1:width_px),
        ones(height_px),
    )
    Model.Image(
        height_px,
        width_px,
        pixels,
        band_index,
        wcs,
        psf,
        0, 0, 0, # run, camcol, field
        sky_intensity,
        fill(nelec_per_nmgy, height_px),
        Model.RawPSF(Matrix{Float64}(0, 0), 0, 0, Array{Float64,3}(0, 0, 0)),
    )
end

function make_images(band_extensions::Vector{FitsImage})
    map(enumerate(band_extensions)) do pair
        band_index, extension = pair
        make_image(
            extension.pixels,
            band_index,
            extension.wcs,
            make_psf_from_header(extension.header),
            convert(Float64, extension.header["CLSKY"]),
            convert(Float64, extension.header["CLIOTA"]),
        )
    end
end

## Create an initialization catalog for Celeste

function typical_band_fluxes(is_star::Bool)
    source_type_index = is_star ? 1 : 2
    prior_parameters::Model.PriorParams = Model.load_prior()
    # this is the mode. brightness is log normal.
    reference_band_flux = exp(
        prior_parameters.flux_mean[source_type_index] - prior_parameters.flux_var[source_type_index]
    )
    # Band relative intensities are a mixture of lognormals. Which mixture component has the most
    # weight?
    dominant_component = indmax(prior_parameters.k[:, source_type_index])
    # What are the most typical log relative intensities for that component?
    color_log_ratios = (
        prior_parameters.color_mean[:, dominant_component, source_type_index]
        - diag(prior_parameters.color_cov[:, :, dominant_component, source_type_index])
    )
    fluxes_from_colors(reference_band_flux, color_log_ratios)
end

function make_catalog_entry(x_position_world_coords::Float64,
                            y_position_world_coords::Float64)
    Model.CatalogEntry(
        [x_position_world_coords, y_position_world_coords],
        false, # is_star
        typical_band_fluxes(true),
        typical_band_fluxes(false),
        0.1, # gal_frac_dev
        0.7, # gal_ab
        pi / 4, # gal_angle
        4., # gal_scale
    )
end

ensure_small_flux(value) = (isna(value) || value <= 0) ? 1e-6 : value

na_to_default(value, default) = isna(value) ? default : value

function make_catalog_entry(row::DataFrameRow)
    color_log_ratios = DataArray{Float64}(DataArray(Any[
        row[:color_log_ratio_ug],
        row[:color_log_ratio_gr],
        row[:color_log_ratio_ri],
        row[:color_log_ratio_iz],
    ]))
    fluxes = fluxes_from_colors(row[:reference_band_flux_nmgy], color_log_ratios)
    fluxes = convert(Vector{Float64}, ensure_small_flux.(fluxes))
    minor_major_axis_ratio = na_to_default(row[:minor_major_axis_ratio], 0.8)
    Model.CatalogEntry(
        [row[:right_ascension_deg], row[:declination_deg]],
        row[:is_star] > 0.5,
        fluxes,
        fluxes,
        na_to_default(row[:de_vaucouleurs_mixture_weight], 0.5),
        minor_major_axis_ratio,
        na_to_default(row[:angle_deg], 0.) / 180.0 * pi,
        na_to_default(row[:half_light_radius_px] / sqrt(minor_major_axis_ratio), 2.)
        )
end

function make_initialization_catalog(catalog::DataFrame, use_full_initialzation::Bool)
    position_offset_width = SDSS_ARCSEC_PER_PIXEL / ARCSEC_PER_DEGREE # 1 pixel, in degrees
    map(eachrow(catalog)) do row
        if use_full_initialzation
            make_catalog_entry(row)
        else
            make_catalog_entry(
                row[:right_ascension_deg] + position_offset_width,
                row[:declination_deg] - 0.5 * position_offset_width
            )
        end
    end
end

################################################################################
# Support for generating imagery using Synthetic.jl
################################################################################

struct ImageGeometry
    height_px::Int64
    width_px::Int64
    world_coordinate_origin::Tuple{Float64, Float64} # (ra, dec)
    ra_degrees_per_pixel::Float64
    dec_degrees_per_pixel::Float64
end

function get_image_geometry(catalog_data::DataFrame; field_expand_arcsec=20.0)
    min_ra_deg = minimum(catalog_data[:right_ascension_deg])
    max_ra_deg = maximum(catalog_data[:right_ascension_deg])
    min_dec_deg = minimum(catalog_data[:declination_deg])
    max_dec_deg = maximum(catalog_data[:declination_deg])

    width_arcsec = (max_ra_deg - min_ra_deg) * ARCSEC_PER_DEGREE + 2 * field_expand_arcsec
    height_arcsec = (max_dec_deg - min_dec_deg) * ARCSEC_PER_DEGREE + 2 * field_expand_arcsec
    width_px = convert(Int64, round(width_arcsec / SDSS_ARCSEC_PER_PIXEL))
    height_px = convert(Int64, round(height_arcsec / SDSS_ARCSEC_PER_PIXEL))

    dec_degrees_per_pixel = SDSS_ARCSEC_PER_PIXEL / ARCSEC_PER_DEGREE
    ra_degrees_per_pixel = dec_degrees_per_pixel / cosd(min_dec_deg)

    ImageGeometry(
        height_px,
        width_px,
        (
            min_ra_deg - field_expand_arcsec / ARCSEC_PER_DEGREE,
            min_dec_deg - field_expand_arcsec / ARCSEC_PER_DEGREE,
        ),
        ra_degrees_per_pixel,
        dec_degrees_per_pixel,
    )
end

function make_template_images(
    catalog_data::DataFrame, psf_sigma_px::Float64, band_sky_level_nmgy::Vector{Float64},
    band_nelec_per_nmgy::Vector{Float64}
)
    @assert length(band_sky_level_nmgy) == 5
    @assert length(band_nelec_per_nmgy) == 5
    geometry = get_image_geometry(catalog_data)
    println("  Image dimensions $(geometry.height_px) H x $(geometry.width_px) W px")
    wcs = WCS.WCSTransform(
        2, # dimensions
        # reference pixel coordinates...
        crpix=[1., 1.],
        # ...and corresponding reference world coordinates
        crval=[geometry.world_coordinate_origin[1], geometry.world_coordinate_origin[2]],
        # this WCS is a simple linear transformation
        ctype=["RA---TAN", "DEC--TAN"],
        cunit=["deg", "deg"],
        # these are [du/dx du/dy; dv/dx dv/dy]. (u, v) = world coords, (x, y) = pixel coords.
        pc=[0. geometry.ra_degrees_per_pixel; geometry.dec_degrees_per_pixel 0.],
    )
    map(1:5) do band
        make_image(
            zeros(Float32, (geometry.height_px, geometry.width_px)),
            band,
            wcs,
            make_simple_psf(psf_sigma_px),
            band_sky_level_nmgy[band],
            band_nelec_per_nmgy[band],
        )
    end
end

## Parse a FITS header from a raw string representation

# It's silly that we have to do this, but the Julia FITSIO library doesn't support this (it can only
# read a header from a file), while the Julia WCS library will only generate a header in raw string
# form. (The Julia FITSIO library also won't write a raw string header to a file, so scrap that idea
# :).) Fortunately, the FITS header format is a simple fixed-width textual format with only a few
# data types. See https://fits.gsfc.nasa.gov/fits_primer.html.

function parse_fits_header_value(raw_value::AbstractString)
    if raw_value == "T"
        true
    elseif raw_value == "F"
        false
    elseif raw_value[1] == '\''
        convert(String, raw_value[2:(end - 1)])
    elseif ismatch(r"^[0-9]+$", raw_value)
        parse(Int64, raw_value)
    else
        parse(Float64, raw_value)
    end
end

function parse_fits_header_from_string(header_string::AbstractString)
    header = FITSIO.FITSHeader(String[], [], String[])
    start_position = 1
    while start_position < length(header_string)
        field_string = header_string[start_position:(start_position + 79)]
        field_name = strip(field_string[1:8])
        if length(field_name) != 0 && field_string[9:10] == "= "
            field_parts = split(field_string[11:80], "/", limit=2)
            raw_value = field_parts[1]
            header[field_name] = parse_fits_header_value(strip(raw_value))
            if length(field_parts) == 2
                comment = strip(field_parts[2])
                if length(comment) > 0
                    FITSIO.set_comment!(header, field_name, convert(String, comment))
                end
            end
        end
        start_position += 80
    end
    header
end

function save_images_to_fits(filename::String, images::Vector{Model.Image})
    println("Writing images to $filename...")
    fits_file = FITSIO.FITS(filename, "w")
    for band_image in images
        header = parse_fits_header_from_string(WCS.to_header(band_image.wcs))
        serialize_psf_to_header(band_image.psf, header)
        header["CLSKY"] = mean(band_image.sky.sky_small) * mean(band_image.sky.calibration)
        FITSIO.set_comment!(header, "CLSKY", "Mean sky background per pixel, nMgy")
        header["CLIOTA"] = mean(band_image.nelec_per_nmgy)
        FITSIO.set_comment!(header, "CLIOTA", "Gain, nelec per nMgy")
        write(
            fits_file,
            band_image.pixels,
            header=header,
            name="CELESTE_FIELD_$(band_image.b)",
        )
    end
    close(fits_file)
end

################################################################################
# Score a set of predictions against a ground truth catalog
################################################################################

COLOR_COLUMNS = [:color_log_ratio_ug, :color_log_ratio_gr, :color_log_ratio_ri, :color_log_ratio_iz]

ABSOLUTE_ERROR_COLUMNS = vcat(
    [:de_vaucouleurs_mixture_weight, :minor_major_axis_ratio, :half_light_radius_px],
    COLOR_COLUMNS,
)

function degrees_to_diff(a, b)
    angle_between = abs(a - b) % 180
    min.(angle_between, 180 - angle_between)
end

"""
Given two results data frame, one containing ground truth (i.e Coadd)
and one containing predictions (i.e., either Primary of Celeste),
compute an a data frame containing each prediction's error.
(It's not an average of the errors, it's each error.)
Let's call the return type of this function an \"error data frame\".
"""
function get_error_df(truth::DataFrame, predicted::DataFrame)
    errors = DataFrame(
        is_saturated=predicted[:is_saturated],
    )

    predicted_galaxy = predicted[:is_star] .< .5
    true_galaxy = truth[:is_star] .< .5
    errors[:missed_stars] = ifelse.(!true_galaxy, predicted_galaxy, NA)
    errors[:missed_galaxies] = ifelse.(true_galaxy, !predicted_galaxy, NA)

    errors[:position] =
        (ARCSEC_PER_DEGREE / SDSS_ARCSEC_PER_PIXEL) .*
        angular_separation.(truth[:right_ascension_deg],
                            truth[:declination_deg],
                            predicted[:right_ascension_deg],
                            predicted[:declination_deg])

    # compare flux in both mags and nMgy for now
    errors[:reference_band_flux_mag] = abs(
        flux_to_mag.(truth[:reference_band_flux_nmgy], 3)
        .- flux_to_mag.(predicted[:reference_band_flux_nmgy], 3)
    )
    errors[:reference_band_flux_nmgy] = abs(
        truth[:reference_band_flux_nmgy] .- predicted[:reference_band_flux_nmgy]
    )
    errors[:angle_deg] = degrees_to_diff(truth[:angle_deg], predicted[:angle_deg])

    for column_symbol in ABSOLUTE_ERROR_COLUMNS
        errors[column_symbol] = abs(truth[column_symbol] - predicted[column_symbol])
    end
    for color_column in COLOR_COLUMNS
        # to match up with Stripe82Score, which used differences of mags
        errors[color_column] *= 2.5 / log(10)
    end

    errors
end

function is_good_row(truth_row::DataFrameRow, error_row::DataFrameRow, column_name::Symbol)
    if isna(error_row[column_name]) || isnan(error_row[column_name])
        return false
    elseif !isna(truth_row[:half_light_radius_px]) && truth_row[:half_light_radius_px] > 20
        return false
    elseif truth_row[:is_saturated] || error_row[:is_saturated]
        return false
    end

    if column_name in [:minor_major_axis_ratio, :half_light_radius_px, :angle_deg,
                       :de_vaucouleurs_mixture_weight]
        has_mixture_weight = !isna(truth_row[:de_vaucouleurs_mixture_weight])
        if has_mixture_weight && (0.05 < truth_row[:de_vaucouleurs_mixture_weight] < 0.95)
            return false
        end
    end
    if column_name == :angle_deg
        if !isna(truth_row[:minor_major_axis_ratio]) && truth_row[:minor_major_axis_ratio] > .6
            return false
        end
    end
    return true
end

function filter_rows(truth::DataFrame, errors::DataFrame, column_name::Symbol)
    map(zip(eachrow(truth), eachrow(errors))) do rows
        (truth_row, error_row) = rows
        is_good_row(truth_row, error_row, column_name)
    end
end

function score_column(errors::DataArray)
    DataFrame(
        N=length(errors),
        first=mean(errors),
    )
end

function score_column(first_errors::DataArray, second_errors::DataArray)
    @assert length(first_errors) == length(second_errors)
    scores = score_column(first_errors)
    scores[:second] = mean(second_errors)
    diffs = first_errors .- second_errors
    scores[:diff] = mean(diffs)
    scores[:diff_sd] = std(abs.(diffs)) / sqrt(length(diffs))
    scores
end

function get_scores_df(
    truth::DataFrame, first_errors::DataFrame, second_errors::Nullable{DataFrame}
)
    score_rows = DataFrame[]
    for column_name in names(first_errors)
        good_row = filter_rows(truth, first_errors, column_name)
        if !isnull(second_errors)
            good_row .&= filter_rows(truth, get(second_errors), column_name)
        end
        if sum(good_row) <= 1
            continue
        else
            if isnull(second_errors)
                row = score_column(first_errors[good_row, column_name])
            else
                row = score_column(
                    first_errors[good_row, column_name],
                    get(second_errors)[good_row, column_name],
                )
            end

            row[:field] = column_name
            push!(score_rows, row)
        end
    end
    vcat(score_rows...)
end

"""
    match_catalogs(truth, predictions)

Return subset of rows in `truth` and `predictions`, such that each entry in
`truth` has a corresponding match in all `predictions`. A "match" is the
closest object within `tol` degrees. The output data frames all have the same
number of rows, and rows correspond to matching objects.
"""
function match_catalogs(truth::DataFrame, prediction_dfs::Vector{DataFrame};
                        tol::Float64=(0.396 / 3600.))

    matched = trues(nrow(truth))
    idxs = Vector{Int}[]
    for prediction in prediction_dfs
        idx, dists = match_coordinates(truth[:right_ascension_deg],
                                       truth[:declination_deg],
                                       prediction[:right_ascension_deg],
                                       prediction[:declination_deg])
        matched .&= dists .< tol
        push!(idxs, idx)
    end

    # For each row in `truth`, `matched` is now true if and only if all
    # predictions have a close match.
    #
    # `idxs[i]` is same length as `truth` and contains the index in
    # `prediction_dfs[i]` that is the closest match to each row in
    # `truth`.
    matched_truth = truth[matched, :]
    matched_predictions = [prediction[idx[matched], :]
                           for (prediction, idx) in zip(prediction_dfs, idxs)]

    return matched_truth, matched_predictions
end

function score_predictions(truth::DataFrame, prediction_dfs::Vector{DataFrame})
    matched_truth, matched_prediction_dfs = match_catalogs(truth, prediction_dfs)
    error_dfs = [get_error_df(matched_truth, predictions) for predictions in matched_prediction_dfs]
    @assert length(prediction_dfs) <= 2
    get_scores_df(
        matched_truth,
        error_dfs[1],
        length(error_dfs) > 1 ? Nullable(error_dfs[2]) : Nullable{DataFrame}(),
    )
end

function get_uncertainty_df(truth::DataFrame, predictions::DataFrame)
    assert_columns_are_present(predictions, STDERR_COLUMNS)
    matched_truth, matched_prediction_dfs = match_catalogs(truth, [predictions])
    matched_predictions = matched_prediction_dfs[1]

    valid_rows = (matched_truth[:reference_band_flux_nmgy] .> 0)
    matched_truth = matched_truth[valid_rows, :]
    matched_predictions = matched_predictions[valid_rows, :]

    get_errors(column, map_fn) =
        map_fn.(matched_predictions[column]) .- map_fn.(matched_truth[column])
    get_errors(column) = get_errors(column, x -> x)
    errors = [
        get_errors(:reference_band_flux_nmgy, log),
        get_errors(:color_log_ratio_ug),
        get_errors(:color_log_ratio_gr),
        get_errors(:color_log_ratio_ri),
        get_errors(:color_log_ratio_iz),
    ]
    std_errs = [
        matched_predictions[:log_reference_band_flux_stderr],
        matched_predictions[:color_log_ratio_ug_stderr],
        matched_predictions[:color_log_ratio_gr_stderr],
        matched_predictions[:color_log_ratio_ri_stderr],
        matched_predictions[:color_log_ratio_iz_stderr],
    ]
    names = [:log_reference_band_flux_nmgy, :color_log_ratio_ug, :color_log_ratio_gr,
             :color_log_ratio_ri, :color_log_ratio_iz]

    mapreduce(vcat, zip(names, errors, std_errs)) do values
        name, error, std_err = values
        DataFrame(name=fill(name, length(error)),
                  error=error,
                  posterior_std_err=std_err)
    end
end

function score_uncertainty(uncertainty_df::DataFrame)
    mapreduce(vcat, groupby(uncertainty_df, :name)) do group_df
        abs_error_sds = abs.(group_df[:error] ./ group_df[:posterior_std_err])
        abs_error_sds = abs_error_sds[!isna(abs_error_sds)]
        DataFrame(
            field=group_df[1, :name],
            within_half_sd=mean(abs_error_sds .<= 1/2),
            within_1_sd=mean(abs_error_sds .<= 1),
            within_2_sd=mean(abs_error_sds .<= 2),
            within_3_sd=mean(abs_error_sds .<= 3),
        )
    end
end

################################################################################
# Utilities
################################################################################

# Run Celeste with any combination of single/joint inference
function run_celeste(
    config::Config, catalog_entries, target_sources, images;
    use_joint_inference=false,
)
    neighbor_map = Infer.find_neighbors(target_sources, catalog_entries, images)
    if use_joint_inference
        ParallelRun.one_node_joint_infer(
            config,
            catalog_entries,
            target_sources,
            neighbor_map,
            images,
        )
    else
        ParallelRun.one_node_single_infer(
            config,
            catalog_entries,
            target_sources,
            neighbor_map,
            images,
        )
    end
end

end # module AccuracyBenchmark


#===============================================================================

    optical_test_dual_channel.jl

===============================================================================#

import DataStructures.OrderedDict
import StaticArrays.SVector
import Memento: debug, warn

#===============================================================================
    constants >>
===============================================================================#

## channel descriptors
const CHANNELS = DYES = SVector(1,2)
const DYE_SYMBOLS = SVector(:FAM, :HEX)
const OPTICAL_TEST_SYMBOLS = SVector(:baseline, :water, DYE_SYMBOLS...)

## preset values >>

## bounds of signal-to-noise ratio (SNR)
const SNR_FAM_CH1_MIN = 0.75
const SNR_FAM_CH2_MAX = 1
const SNR_HEX_CH1_MAX = 0.50
const SNR_HEX_CH2_MIN = 0.88

## signal-to-noise ratio discriminant functions for each well
dscrmnt_snr_fam(snr_2chs) = [snr_2chs[1] > SNR_FAM_CH1_MIN, snr_2chs[2] < SNR_FAM_CH2_MAX]
dscrmnt_snr_hex(snr_2chs) = [snr_2chs[1] < SNR_HEX_CH1_MAX, snr_2chs[2] > SNR_HEX_CH2_MIN]
const dscrmnts_snr = OrderedDict(map(1:2) do i
    DYE_SYMBOLS[i] => [dscrmnt_snr_fam, dscrmnt_snr_hex][i]
end) ## do i

## maximum and minimum fluorescence values: channel 1, channel 2
const WATER_MAX = [32000, 5000]
const WATER_MIN = [1000, -1000]

#===============================================================================
    function definition >>
===============================================================================#

## called by dispatch()
function act(
    ::Type{Val{optical_test_dual_channel}},
    req             ::Associative; ## keys: "baseline", "water", "FAM", "HEX"
    out_format      ::OutputFormat = pre_json_output
    ## remove MySqldependency
    #
    # db_conn ::MySQL.MySQLHandle,
    # exp_id ::Integer,
    # calib_info ::Union{Integer,OrderedDict}; ## keys: "baseline", "water", "channel_1", "channel_2". Each value's "calibration_id" value is the same as `exp_id`
    #
    # start: arguments that might be passed by upstream code
    # well_nums ::AbstractVector =[],
)
    function SNR_test(w, label)
        (baseline_2chs, water_fluo_2chs, signal_fluo_2chs) =
            map(key -> fluo_dict[key][w, :], [:baseline, :water, label])
        snr_2chs =
            (signal_fluo_2chs .- water_fluo_2chs) ./ (signal_fluo_2chs .- baseline_2chs)
        transpose(dscrmnts_snr[label](transpose(snr_2chs)))
    end

    debug(logger, "at act(::Type{Val{optical_test_dual_channel}})")

    ## remove MySql dependency
    #
    # fluo_qry_2b = "SELECT step_id, well_num, fluorescence_value, channel
    #     FROM fluorescence_data
    #     WHERE experiment_id = $exp_id AND cycle_num = 1 AND step_id is not NULL
    #     well_constraint
    #     ORDER BY well_num, channel
    # "
    # fluo_data, fluo_well_nums = get_mysql_data_well(
    #     well_nums, fluo_qry_2b, db_conn, false
    # )
    #
    # num_wells = length(fluo_well_nums)

    # fluo_dict = OrderedDict(map(old_labels) do label ## old
    fluo_dict =
        OrderedDict(
            map(OPTICAL_TEST_SYMBOLS) do label
                label =>
                    mapreduce(
                        ## old
                        # fluo_data[:fluorescence_value][
                        #     (fluo_data[:step_id] .== calib_info[label]["step_id"]) .& (fluo_data[:channel] .== channel)
                        # ]
                        channel -> req[string(label)][FLUORESCENCE_VALUE_KEY][channel],
                        hcat,
                        CHANNELS)
            end)
    num_wells = size(fluo_dict[:baseline], 1)
    #
    bool_dict = OrderedDict(:baseline => fill(true, num_wells, length(CHANNELS)))
    ## water test
    bool_dict[:water] =
        mapreduce(
            channel ->
                map(fluo_dict[:water][:, channel]) do fluo_pw
                    WATER_MIN[channel] < fluo_pw < WATER_MAX[channel]
                end,
            hcat,
            CHANNELS)
    ## FAM and HEX SNR test
    for label in DYE_SYMBOLS
        bool_dict[label] =
            mapreduce(
                well -> SNR_test(well, label),
                vcat,
                1:num_wells)
    end
    #
    ## organize "optical_data"
    optical_data =
        map(1:num_wells) do well
            OrderedDict(
                map(OPTICAL_TEST_SYMBOLS) do label
                    label =>
                        map(CHANNELS) do channel
                            (fluo_dict[label][well, channel],
                                bool_dict[label][well, channel])
                        end ## do channel

                end) ## do label
        end ## do well
    #
    ## FAM and HEX self-calibrated
    ## ((signal_of_dye_x_in_channel_k - water_in_channel_k) /
    ##     (signal_of_target_dye_in_channel_k - water_in_channel_k); x=FAM,HEX; k=1,2)
    ## ratio (self_calib_of_dye_x_where_k_equals_1 / self_calib_of_dye_x_where_k_equals_2) test.
    #
    ## Note:
    ## Baseline is not subtracted because it is not part of the calibration procedure.
    #
    ## Issues:
    ## Is it more sensible to calculate target:off-target ratio than channel_1:channel_2?
    ## Calculation of ratio may fail if water >= signal values,
    ## reporting negative or infinite values
    #
    ## substract water values from signal values
    swd_vec =
        map(DYE_SYMBOLS) do label
            map(CHANNELS) do channel
                fluo_dict[label][:, channel] .- fluo_dict[:water][:, channel]
            end ## do channel
        end ## do label
    ## calculate normalization values from data in target channels
    swd_normd =
        map(CHANNELS) do target
            sweep(mean)(/)(swd_vec[target][target]) ## dye == channel
        end
    ## normalize signal data
    self_calib_vec =
        map(swd_vec) do swd_dye
            map(CHANNELS) do channel
                swd_dye[channel] ./ swd_normd[channel]
            end ## do channel
        end ## do swd_dye
    #
    ## call as invalid analysis if there are negative or zero values in the normalized data
    ## that will cause the channel1:channel2 ratio to be zero, infinite, or negative
    ## devectorized
    error_msgs = Vector{String}()
    for dye in DYES, channel in CHANNELS, value in self_calib_vec[dye][channel]
        if value <= 0.0
            ## call as invalid analysis instead of raising an error
            push!(error_msgs, "zero or negative values in the self-calibrated fluorescence data")
            warn(logger, error_msgs[end])
            break ## exit nested loops
        end ## if
    end ## for dye, channel, value
    #
    ## calculate channel1:channel2 ratios
    ch12_ratios =
        OrderedDict(
            map(DYES) do dye
                sc_dye = self_calib_vec[dye]
                DYE_SYMBOLS[dye] => round.(sc_dye[1] ./ sc_dye[2], JSON_DIGITS)
            end) # do channel_i
    if !(ch12_ratios |> values |> mold(x -> all(isfinite.(x))) |> all) ||
        (ch12_ratios |> values |> mold(x -> any(x .<= 0))      |> any)
            push!(error_msgs, "zero, negative, or infinite values of channel 1 : channel 2 ratio")
            warn(logger, error_msgs[end])
    end

    ## return values
    output = OrderedDict(
        :optical_data       => optical_data,
        Symbol("Ch1:Ch2")   => ch12_ratios,
        :valid              => length(error_msgs) == 0,
        :error              => join(error_msgs, "; "))
    return output |> out(out_format)
end ## act(::Type{Val{optical_test_dual_channel}})