Index: trunk/ppTranslate/Makefile.am
===================================================================
--- trunk/ppTranslate/Makefile.am	(revision 25923)
+++ trunk/ppTranslate/Makefile.am	(revision 25923)
@@ -0,0 +1,5 @@
+SUBDIRS = src
+
+CLEANFILES = *.pyc *~ core core.*
+
+ACLOCAL_AMFLAGS = -I m4
Index: trunk/ppTranslate/autogen.sh
===================================================================
--- trunk/ppTranslate/autogen.sh	(revision 25923)
+++ trunk/ppTranslate/autogen.sh	(revision 25923)
@@ -0,0 +1,103 @@
+#!/bin/sh
+# Run this to generate all the initial makefiles, etc.
+
+srcdir=`dirname $0`
+test -z "$srcdir" && srcdir=.
+
+ORIGDIR=`pwd`
+cd $srcdir
+
+PROJECT=ppTranslate
+TEST_TYPE=-f
+# change this to be a unique filename in the top level dir
+FILE=autogen.sh
+
+DIE=0
+
+LIBTOOLIZE=libtoolize
+ACLOCAL="aclocal -I `pwd`/m4 $ACLOCAL_FLAGS"
+AUTOHEADER=autoheader
+AUTOMAKE=automake
+AUTOCONF=autoconf
+
+($LIBTOOLIZE --version) < /dev/null > /dev/null 2>&1 || {
+        echo
+        echo "You must have $LIBTOOLIZE installed to compile $PROJECT."
+        echo "Download the appropriate package for your distribution,"
+        echo "or get the source tarball at http://ftp.gnu.org/gnu/libtool/"
+        DIE=1
+}
+
+($ACLOCAL --version) < /dev/null > /dev/null 2>&1 || {
+        echo
+        echo "You must have $ACLOCAL installed to compile $PROJECT."
+        echo "Download the appropriate package for your distribution,"
+        echo "or get the source tarball at http://ftp.gnu.org/gnu/automake/"
+        DIE=1
+}
+
+($AUTOHEADER --version) < /dev/null > /dev/null 2>&1 || {
+        echo
+        echo "You must have $AUTOHEADER installed to compile $PROJECT."
+        echo "Download the appropriate package for your distribution,"
+        echo "or get the source tarball at http://ftp.gnu.org/gnu/autoconf/"
+        DIE=1
+}
+
+($AUTOMAKE --version) < /dev/null > /dev/null 2>&1 || {
+        echo
+        echo "You must have $AUTOMAKE installed to compile $PROJECT."
+        echo "Download the appropriate package for your distribution,"
+        echo "or get the source tarball at http://ftp.gnu.org/gnu/automake/"
+        DIE=1
+}
+
+($AUTOCONF --version) < /dev/null > /dev/null 2>&1 || {
+        echo
+        echo "You must have $AUTOCONF installed to compile $PROJECT."
+        echo "Download the appropriate package for your distribution,"
+        echo "or get the source tarball at http://ftp.gnu.org/gnu/autoconf/"
+        DIE=1
+}
+
+if test "$DIE" -eq 1; then
+        exit 1
+fi
+
+test $TEST_TYPE $FILE || {
+        echo "You must run this script in the top-level $PROJECT directory"
+        exit 1
+}
+
+if test -z "$*"; then
+        echo "I am going to run ./configure with no arguments - if you wish "
+        echo "to pass any to it, please specify them on the $0 command line."
+fi
+
+$LIBTOOLIZE --copy --force || echo "$LIBTOOLIZE failed"
+$ACLOCAL || echo "$ACLOCAL failed"
+$AUTOHEADER || echo "$AUTOHEADER failed"
+$AUTOMAKE --add-missing --force-missing --copy || echo "$AUTOMAKE  failed"
+$AUTOCONF || echo "$AUTOCONF failed"
+
+cd $ORIGDIR
+
+run_configure=true
+for arg in $*; do
+    case $arg in
+        --no-configure)
+            run_configure=false
+            ;;
+        *)
+            ;;
+    esac
+done
+
+if $run_configure; then
+    $srcdir/configure --enable-maintainer-mode "$@"
+    echo
+    echo "Now type 'make' to compile $PROJECT."
+else
+    echo
+    echo "Now run 'configure' and 'make' to compile $PROJECT."
+fi
Index: trunk/ppTranslate/configure.ac
===================================================================
--- trunk/ppTranslate/configure.ac	(revision 25923)
+++ trunk/ppTranslate/configure.ac	(revision 25923)
@@ -0,0 +1,44 @@
+dnl Process this file with autoconf to produce a configure script.
+AC_PREREQ(2.61)
+
+AC_INIT([ppMops], [0.1.1], [ipp-support@ifa.hawaii.edu])
+AC_CONFIG_SRCDIR([src])
+AC_CONFIG_MACRO_DIR([m4])
+
+AM_INIT_AUTOMAKE([1.6 foreign dist-bzip2])
+AM_CONFIG_HEADER([src/config.h])
+AM_MAINTAINER_MODE
+
+IPP_STDCFLAGS
+
+AC_LANG(C)
+AC_GNU_SOURCE
+AC_PROG_CC_C99
+AC_PROG_INSTALL
+AC_PROG_LIBTOOL
+AC_SYS_LARGEFILE
+
+PKG_CHECK_MODULES([PSLIB], [pslib >= 1.0.0])
+PKG_CHECK_MODULES([PSMODULES], [psmodules >= 1.0.0])
+CHECK_ZLIB
+
+AC_PATH_PROG([ERRORCODES], [psParseErrorCodes], [missing])
+if test "$ERRORCODES" = "missing" ; then
+  AC_MSG_ERROR([psParseErrorCodes is required])
+fi
+
+dnl Set CFLAGS for build
+IPP_STDOPTS
+CFLAGS="${CFLAGS} -Wall -Werror"
+
+IPP_VERSION
+
+AC_SUBST([PPMOPS_CFLAGS])
+AC_SUBST([PPMOPS_LIBS])
+
+AC_CONFIG_FILES([
+  Makefile
+  src/Makefile
+])
+
+AC_OUTPUT
Index: trunk/ppTranslate/doc/MOPS_ICD_lite.txt
===================================================================
--- trunk/ppTranslate/doc/MOPS_ICD_lite.txt	(revision 25923)
+++ trunk/ppTranslate/doc/MOPS_ICD_lite.txt	(revision 25923)
@@ -0,0 +1,192 @@
+== General notes ==
+
+Though efforts have been made to avoid this from occurring, where
+header keyword names are longer than the usual 8 character maximum,
+the HIERARCH convention will be used.
+
+Celestial coordinates are in ICRS.
+
+Magnitudes are AB magnitudes (flux zero point is 3631 Jy).
+
+Unless otherwise noted, error values are statistical only, and include
+no contribution by systematic errors.
+
+== Primary header ==
+
+The primary header will not contain any information beyond that
+written automatically by cfitsio, and is present only for compliance
+with the FITS standard.
+
+== FITS table ==
+
+The FITS table will include all detections within an exposure.
+
+Duplicates from overlapping skycells will have been filtered out by
+IPP, with the measurements from the source closest to the centre of a
+skycell included (and all others discarded).
+
+=== Header ===
+
+The header will contain information relevant to the exposure as a whole.
+
+ * Version information
+  * SWSOURCE (string): source of software (e.g., "60eb6cdc-a59c-4636-a4e0-dba66a9721fd")
+  * SWVERSN (string): version of software (e.g., "trunk/ppMops@24658")
+
+ * Provenance information
+  * EXP_NAME (string): Exposure name (e.g., "o1234g5678")
+  * EXP_ID (S64): Exposure identifier
+  * CHIP_ID (S64): Chip stage identifier
+  * CAM_ID (S64): Camera stage identifier
+  * FAKE_ID (S64): Fake stage identifier
+  * WARP_ID (S64): Warp stage identifier
+  * DIFF_ID (S64): Diff stage identifier
+  * DIFF_POS (boolean): Sense of subtraction; T for forward, F for backward
+
+ * Exposure details
+  * MJD-OBS (F64): TAI MJD of exposure mid-point
+  * RA (string): Reported Right Ascension of telescope boresight, sexagesimal hours (e.g., "12:34:56.789")
+  * DEC (string): Reported Declination of telescope boresight, sexagesimal degrees (e.g., "-12:34:56.78")
+  * TEL_ALT (F64): Reported telescope altitude, degrees
+  * TEL_AZ (F64): Reported telescope azimuth, degrees
+  * EXPTIME (F32): Exposure time, seconds
+  * ROTANGLE (F64): Rotator angle, degrees
+  * FILTER (string): Filter name (e.g., "r.00000")
+  * AIRMASS (F32): Airmass for exposure
+  * OBSCODE (string): IAU observatory code (i.e., "F51" for PS1)
+  * SEEING (F32): Measured seeing at diff stage, arcsec
+  * MAGZP (F32): Magnitude zero point
+  * MAGZPERR (F32): Error in magnitude zero point
+  * ASTRORMS (F32): RMS of astrometric fit, arcsec
+
+ * Detection efficiency
+  * DE_MAGnn (F32): Magnitude (calibrated) for detection efficiency
+  * DE_EFFnn (F32): Detection efficiency (0..1)
+
+==== Current limitations ====
+
+The IPP is not yet calculating detection efficiencies (it is still
+being developed).  Further, it is not yet clear how to merge the
+detection efficiency measurements for different skycells.  Until these
+issues are resolved, the detection efficiency values will be fake.
+
+==== Differences from original ICD ====
+
+ * SWSOURCE, SWVERSN has replaced TABLEVER which was never defined
+ * All of the "Provenance information" has replaced FPA_ID, for better tracking of the source of each detection
+ * SEEING and MAGZP added as indicators of the quality of the exposure
+ * MAGZPERR added to make absolute (i.e., across exposures) magnitude errors more accurate
+ * ASTRORMS added to make absolute (i.e., across exposures) astrometry errors more accurate
+ * DE_MAGnn and DE_EFFnn replace DE1 through DE10, which were never well defined
+ * Removed LIMITMAG, which was never well defined, and is unnecessary given the DE_MAGnn and DE_EFFnn
+
+=== Table ===
+
+The table will contain information relevant to the individual
+detections within the exposure.
+
+ * RA (F64): Right Ascension of detection centre, degrees
+ * RA_ERR (F64): Error in RA, degrees
+ * DEC (F64): Declination of detection centre, degrees
+ * DEC_ERR (F64): Error in DEC, degrees
+ * MAG (F32): Calibrated magnitude of detection
+ * MAG_ERR (F32): Error in MAG
+ * STARPSF (F32): A PSF/extended source separator
+ * ANGLE (F64): Angle of trail fit to source, degrees E of N
+ * ANGLE_ERR (F64): Error in ANGLE, degrees
+ * LENGTH (F32): Length of trail fit to source, degrees
+ * LENGTH_ERR (F32): Error in LENGTH, degrees
+ * FLAGS (S32): IPP detection flags, bit mask
+ * DIFF_SKYFILE_ID (S64): IPP diff_skyfile_id for source
+
+==== Current limitations ====
+
+The IPP is not yet fitting trails to sources in the difference images.
+Until this is being done, the ANGLE, ANGLE_ERR, LENGTH and LENGTH_ERR
+values will be zero.
+
+The value being written as STARPSF is EXT_NSIGMA from the IPP CMF
+files; it is not clear that this is what MOPS wants, but this is
+probably irrelevant until the trail fitting has been implemented.
+
+==== Differences from original ICD ====
+
+ * RA_DEG, DEC_DEG renamed RA, DEC to match apparent naming policy
+ * *_SIG renamed *_ERR to be more clear
+ * ANG, ANG_SIG, LEN, LEN_SIG spelled out as ANGLE, ANGLE_ERR, LENGTH, LENGTH_ERR
+ * FLUX, FLUX_SIG renamed MAG, MAG_ERR since IPP writes magnitudes
+ * FLAGS added to allow additional weeding out of bad detections
+ * DIFF_SKYFILE_ID added to allow trace back to IPP diff skyfile, for postage stamps
+
+
+=== Example ===
+
+{{{
+XTENSION= 'BINTABLE'           / binary table extension
+BITPIX  =                    8 / 8-bit bytes
+NAXIS   =                    2 / 2-dimensional binary table
+NAXIS1  =                   72 / width of table in bytes
+NAXIS2  =                42032 / number of rows in table
+PCOUNT  =                    0 / size of special data area
+GCOUNT  =                    1 / one data group (required keyword)
+TFIELDS =                   13 / number of fields in each row
+TTYPE1  = 'RA      '           / label for field   1
+TFORM1  = '1D      '           / data format of field: 8-byte DOUBLE
+TTYPE2  = 'RA_ERR  '           / label for field   2
+TFORM2  = '1D      '           / data format of field: 8-byte DOUBLE
+TTYPE3  = 'DEC     '           / label for field   3
+TFORM3  = '1D      '           / data format of field: 8-byte DOUBLE
+TTYPE4  = 'DEC_ERR '           / label for field   4
+TFORM4  = '1D      '           / data format of field: 8-byte DOUBLE
+TTYPE5  = 'MAG     '           / label for field   5
+TFORM5  = '1E      '           / data format of field: 4-byte REAL
+TTYPE6  = 'MAG_ERR '           / label for field   6
+TFORM6  = '1E      '           / data format of field: 4-byte REAL
+TTYPE7  = 'STARPSF '           / label for field   7
+TFORM7  = '1E      '           / data format of field: 4-byte REAL
+TTYPE8  = 'ANGLE   '           / label for field   8
+TFORM8  = '1E      '           / data format of field: 4-byte REAL
+TTYPE9  = 'ANGLE_ERR'          / label for field   9
+TFORM9  = '1E      '           / data format of field: 4-byte REAL
+TTYPE10 = 'LENGTH  '           / label for field  10
+TFORM10 = '1E      '           / data format of field: 4-byte REAL
+TTYPE11 = 'LENGTH_ERR'         / label for field  11
+TFORM11 = '1E      '           / data format of field: 4-byte REAL
+TTYPE12 = 'FLAGS   '           / label for field  12
+TFORM12 = '1J      '           / data format of field: 4-byte INTEGER
+TZERO12 =           2147483648 / offset for unsigned integers
+TSCAL12 =                    1 / data are not scaled
+TTYPE13 = 'DIFF_SKYFILE_ID'    / label for field  13
+TFORM13 = '1K      '           / data format of field: 8-byte INTEGER
+SWSOURCE= '60eb6cdc-a59c-4636-a4e0-dba66a9721fd' / Software source
+SWVERSN = 'branches/pap_mops/ppMops@25227' / Software version
+HISTORY ppMops at 2009-09-02T03:48:46.695783
+HISTORY psLib version: branches/pap_mops/psLib@25227
+HISTORY psLib source: 60eb6cdc-a59c-4636-a4e0-dba66a9721fd
+HISTORY ppMops version: branches/pap_mops/ppMops@25227
+HISTORY ppMops source: 60eb6cdc-a59c-4636-a4e0-dba66a9721fd
+EXP_NAME= 'o4995g0129o'        / Exposure name
+EXP_ID  =                77164 / Exposure identifier
+CHIP_ID =                24019 / Chip stage identifier
+CAM_ID  =                17726 / Cam stage identifier
+FAKE_ID =                10227 / Fake stage identifier
+WARP_ID =                 8842 / Warp stage identifier
+DIFF_ID =                    0 / Diff stage identifier
+DIFF_POS=                    F / Positive subtraction?
+MJD-OBS =     54995.4740598313 / MJD of exposure midpoint
+RA      = '18:25:01.988'       / Right Ascension of boresight
+DEC     = '-17:20:40.069'      / Declination of boresight
+TEL_ALT =            51.951873 / Telescope altitude
+TEL_AZ  =           179.483883 / Telescope azimuth
+EXPTIME =                  38. / Exposure time (sec)
+ROTANGLE=             333.1039 / Rotator position angle
+FILTER  = 'r.00000 '           / Filter name
+AIRMASS =                1.269 / Airmass of exposure
+OBSCODE = 'F51     '           / IAU Observatory code
+SEEING  =             1.678401 / Mean seeing
+MAGZP   =             28.65226 / Magnitude zero point
+MAGZPERR=             0.353063 / Error in magnitude zero point
+ASTRORMS=            0.3111496 / RMS of astrometric fit
+EXTNAME = 'MOPS_TRANSIENT_DETECTIONS'
+END
+}}}
Index: trunk/ppTranslate/m4/check_zlib.m4
===================================================================
--- trunk/ppTranslate/m4/check_zlib.m4	(revision 25923)
+++ trunk/ppTranslate/m4/check_zlib.m4	(revision 25923)
@@ -0,0 +1,87 @@
+dnl @synopsis CHECK_ZLIB()
+dnl
+dnl This macro searches for an installed zlib library. If nothing was
+dnl specified when calling configure, it searches first in /usr/local
+dnl and then in /usr. If the --with-zlib=DIR is specified, it will try
+dnl to find it in DIR/include/zlib.h and DIR/lib/libz.a. If
+dnl --without-zlib is specified, the library is not searched at all.
+dnl
+dnl If either the header file (zlib.h) or the library (libz) is not
+dnl found, the configuration exits on error, asking for a valid zlib
+dnl installation directory or --without-zlib.
+dnl
+dnl The macro defines the symbol HAVE_LIBZ if the library is found. You
+dnl should use autoheader to include a definition for this symbol in a
+dnl config.h file. Sample usage in a C/C++ source is as follows:
+dnl
+dnl   #ifdef HAVE_LIBZ
+dnl   #include <zlib.h>
+dnl   #endif /* HAVE_LIBZ */
+dnl
+dnl @category InstalledPackages
+dnl @author Loic Dachary <loic@senga.org>
+dnl @version 2004-09-20
+dnl @license GPLWithACException
+
+AC_DEFUN([CHECK_ZLIB],
+#
+# Handle user hints
+#
+[AC_MSG_CHECKING(if zlib is wanted)
+AC_ARG_WITH(zlib,
+[  --with-zlib=DIR root directory path of zlib installation [defaults to
+                    /usr/local or /usr if not found in /usr/local]
+  --without-zlib to disable zlib usage completely],
+[if test "$withval" != no ; then
+  AC_MSG_RESULT(yes)
+  if test -d "$withval"
+  then
+    ZLIB_HOME="$withval"
+  else
+    AC_MSG_WARN([Sorry, $withval does not exist, checking usual places])
+  fi
+else
+  AC_MSG_RESULT(no)
+fi])
+
+ZLIB_HOME=/usr/local
+if test ! -f "${ZLIB_HOME}/include/zlib.h"
+then
+        ZLIB_HOME=/usr
+fi
+
+#
+# Locate zlib, if wanted
+#
+if test -n "${ZLIB_HOME}"
+then
+        ZLIB_OLD_LDFLAGS=$LDFLAGS
+        ZLIB_OLD_CPPFLAGS=$LDFLAGS
+        LDFLAGS="$LDFLAGS -L${ZLIB_HOME}/lib"
+        CPPFLAGS="$CPPFLAGS -I${ZLIB_HOME}/include"
+        AC_LANG_SAVE
+        AC_LANG_C
+        AC_CHECK_LIB(z, inflateEnd, [zlib_cv_libz=yes], [zlib_cv_libz=no])
+        AC_CHECK_HEADER(zlib.h, [zlib_cv_zlib_h=yes], [zlib_cv_zlib_h=no])
+        AC_LANG_RESTORE
+        if test "$zlib_cv_libz" = "yes" -a "$zlib_cv_zlib_h" = "yes"
+        then
+                #
+                # If both library and header were found, use them
+                #
+                AC_CHECK_LIB(z, inflateEnd)
+                AC_MSG_CHECKING(zlib in ${ZLIB_HOME})
+                AC_MSG_RESULT(ok)
+        else
+                #
+                # If either header or library was not found, revert and bomb
+                #
+                AC_MSG_CHECKING(zlib in ${ZLIB_HOME})
+                LDFLAGS="$ZLIB_OLD_LDFLAGS"
+                CPPFLAGS="$ZLIB_OLD_CPPFLAGS"
+                AC_MSG_RESULT(failed)
+                AC_MSG_ERROR(either specify a valid zlib installation with --with-zlib=DIR or disable zlib usage with --without-zlib)
+        fi
+fi
+
+])
Index: trunk/ppTranslate/src/Makefile.am
===================================================================
--- trunk/ppTranslate/src/Makefile.am	(revision 25923)
+++ trunk/ppTranslate/src/Makefile.am	(revision 25923)
@@ -0,0 +1,56 @@
+bin_PROGRAMS = ppMops ppMonet
+
+if HAVE_SVNVERSION
+PPTRANSLATE_VERSION=`$(SVNVERSION) ..`
+else
+PPTRANSLATE_VERSION="UNKNOWN"
+endif
+
+if HAVE_SVN
+PPTRANSLATE_BRANCH=`$(SVN) info .. | $(SED) -n -e '/URL:/ h' -e '/Repository Root:/ { x; H; x; s|Repository Root: \(.*\)\nURL: \1\(.*\)|\2| ; s|^/|| ; s|/[a-zA-Z]*/src.*|| ; p }'`
+PPTRANSLATE_SOURCE=`$(SVN) info | $(SED) -n -e 's/Repository UUID: // p'`
+else
+PPTRANSLATE_BRANCH="UNKNOWN"
+PPTRANSLATE_SOURCE="UNKNOWN"
+endif
+
+# Force recompilation of ppMopsVersion.c, since it gets the version information
+ppTranslateVersion.c: ppTranslateVersionDefinitions.h
+ppTranslateVersionDefinitions.h: ppTranslateVersionDefinitions.h.in FORCE
+	-$(RM) ppTranslateVersionDefinitions.h
+	$(SED) -e "s|@PPTRANSLATE_VERSION@|\"$(PPTRANSLATE_VERSION)\"|" -e "s|@PPTRANSLATE_BRANCH@|\"$(PPTRANSLATE_BRANCH)\"|" -e "s|@PPTRANSLATE_SOURCE@|\"$(PPTRANSLATE_SOURCE)\"|" ppTranslateVersionDefinitions.h.in > ppTranslateVersionDefinitions.h
+FORCE: ;
+
+ppMops_CPPFLAGS = $(PSLIB_CFLAGS) $(PSMODULES_CFLAGS) $(PPMOPS_CFLAGS)
+ppMops_LDFLAGS  = $(PSLIB_LIBS) $(PPMOPS_LIBS)
+
+ppMops_SOURCES =		\
+	ppMops.c		\
+	ppTranslateVersion.c	\
+	ppMopsArguments.c	\
+	ppMopsDetections.c	\
+	ppMopsRead.c		\
+	ppMopsWrite.c		\
+	ppMopsMerge.c
+
+ppMonet_CPPFLAGS = $(PSLIB_CFLAGS) $(PSMODULES_CFLAGS) $(PPMONET_CFLAGS)
+ppMonet_LDFLAGS  = $(PSLIB_LIBS) $(PPMONET_LIBS)
+
+ppMonet_SOURCES =		\
+	ppMonet.c		\
+	ppTranslateVersion.c	\
+	ppMonetArguments.c
+
+
+noinst_HEADERS = \
+	ppTranslateVersion.h \
+	ppMops.h \
+	ppMonet.h
+
+
+clean-local:
+	-rm -f TAGS
+
+# Tags for emacs
+tags:
+	etags `find . -name \*.[ch] -print`
Index: trunk/ppTranslate/src/ppMonet.c
===================================================================
--- trunk/ppTranslate/src/ppMonet.c	(revision 25923)
+++ trunk/ppTranslate/src/ppMonet.c	(revision 25923)
@@ -0,0 +1,165 @@
+#ifdef HAVE_CONFIG_H
+#include <config.h>
+#endif
+
+#include <stdio.h>
+#include <string.h>
+#include <zlib.h>
+#include <pslib.h>
+
+#include "ppTranslateVersion.h"
+#include "ppMonet.h"
+
+int main(int argc, char *argv[])
+{
+    psLibInit(NULL);
+
+    ppMonetArguments *args = ppMonetArgumentsParse(argc, argv); // Parsed arguments
+    if (!args) {
+        psErrorStackPrint(stderr, "Error parsing arguments");
+        exit(PS_EXIT_CONFIG_ERROR);
+    }
+
+    psTrace("ppMonet.read", 1, "Reading input detections\n");
+
+    psFits *fits = psFitsOpen(args->input, "r"); // FITS file
+    if (!fits) {
+        psError(PS_ERR_IO, false, "Unable to open input %s", args->input);
+        exit(PS_EXIT_SYS_ERROR);
+    }
+
+    // Set up output file
+    FILE *file = fopen(args->output, "w"); // File handle
+    if (!file) {
+        psErrorStackPrint(stderr, "Error opening file %s", args->output);
+        exit(PS_EXIT_SYS_ERROR);
+    }
+    gzFile gz = gzdopen(fileno(file), "w"); // Gzip file handle
+    gzprintf(gz, "# Pan-STARRS IPP detections for Dave Monet\n");
+    gzprintf(gz, "#\n");
+    gzprintf(gz, "# Translated from %s\n", args->input);
+    psString source = ppTranslateSource(), version = ppTranslateVersion();
+    gzprintf(gz, "# S/W source = %s\n", source);
+    gzprintf(gz, "# S/W version = %s\n", version);
+    gzprintf(gz, "#\n");
+    psFree(source);
+    psFree(version);
+    gzprintf(gz, "# exp_name = %s\n", args->exp_name);
+    gzprintf(gz, "# exp_id = %" PRId64 "\n", args->exp_id);
+    gzprintf(gz, "# chip_id = %" PRId64 "\n", args->chip_id);
+    gzprintf(gz, "# cam_id = %" PRId64 "\n", args->cam_id);
+    gzprintf(gz, "# ZP = %f\n", args->zp);
+    gzprintf(gz, "# ZP error = %f\n", args->zpErr);
+    gzprintf(gz, "# Astrometry rms = %f\n", args->rmsAstrom);
+
+    psFree(args);
+
+    psMetadata *header = psFitsReadHeader(NULL, fits); // Primary header
+    if (!header) {
+        psError(PS_ERR_IO, false, "Unable to read header");
+        exit(PS_EXIT_SYS_ERROR);
+    }
+
+    double raBoresight = psMetadataLookupF64(NULL, header, "RA");
+    double decBoresight = psMetadataLookupF64(NULL, header, "DEC");
+    psString filter = psMemIncrRefCounter(psMetadataLookupStr(NULL, header, "FILTERID"));
+    float airmass = psMetadataLookupF32(NULL, header, "AIRMASS");
+    float exptime = psMetadataLookupF32(NULL, header, "EXPTIME");
+    double posangle = psMetadataLookupF64(NULL, header, "POSANGLE");
+    double rotangle = psMetadataLookupF64(NULL, header, "ROTANGLE");
+    double alt = psMetadataLookupF64(NULL, header, "ALT");
+    double az = psMetadataLookupF64(NULL, header, "AZ");
+    double mjd = psMetadataLookupF64(NULL, header, "MJD-OBS");
+
+    gzprintf(gz, "# RA = %.8lf\n", raBoresight);
+    gzprintf(gz, "# Dec = %.8lf\n", decBoresight);
+    gzprintf(gz, "# Filter = %s\n", filter);
+    gzprintf(gz, "# Airmass = %f\n", airmass);
+    gzprintf(gz, "# ExpTime = %f\n", exptime);
+    gzprintf(gz, "# PA = %lf\n", posangle);
+    gzprintf(gz, "# Rotator = %lf\n", rotangle);
+    gzprintf(gz, "# Alt = %.8lf\n", alt);
+    gzprintf(gz, "# Az = %.8lf\n", az);
+    gzprintf(gz, "# MJD = %.6lf\n", mjd);
+    gzprintf(gz, "#\n");
+    gzprintf(gz, "# chip , x , y , RA , Dec , Mag , MagErr , Width, Flags\n");
+
+    psFree(header);
+
+    int numHDU = psFitsGetSize(fits);   // Number of HDUs
+    for (int i = 1; i < numHDU; i++) {
+        if (!psFitsMoveExtNum(fits, i, false)) {
+            psError(PS_ERR_IO, false, "Unable to move to HDU %d", i);
+            exit(PS_EXIT_SYS_ERROR);
+        }
+
+        if (psFitsGetExtType(fits) != PS_FITS_TYPE_BINARY_TABLE) {
+            psTrace("ppMonet", 1, "Skipping extension %d: not a binary table", i);
+            continue;
+        }
+
+        psMetadata *header = psFitsReadHeader(NULL, fits); // Primary header
+        if (!header) {
+            psError(PS_ERR_IO, false, "Unable to read header %d\n", i);
+            exit(PS_EXIT_SYS_ERROR);
+            return false;
+        }
+
+        const char *extname = psMetadataLookupStr(NULL, header, "EXTNAME");
+        const char *exttype = extname + strlen(extname) - strlen(EXT_TYPE);
+        if (strcmp(exttype, EXT_TYPE) != 0) {
+            psTrace("ppMonet", 1, "Skipping extension %d (%s): not correct type", i, extname);
+            psFree(header);
+            continue;
+        }
+
+        long size = psFitsTableSize(fits); // Size of table
+        if (size <= 0) {
+            psErrorStackPrint(stderr, "Unable to determine size of table %d", i);
+            psFree(header);
+            exit(PS_EXIT_SYS_ERROR);
+        }
+
+        psString chipName = psStringNCopy(extname, strlen(extname) - strlen(EXT_TYPE)); // Name of chip
+        psFree(header);
+
+        psTrace("ppMonet.read", 3, "Reading %ld rows from %s\n", size, extname);
+        psArray *table = psFitsReadTable(fits); // Table of interest
+        if (!table) {
+            psError(PS_ERR_IO, false, "Unable to read table %d", i);
+            psFree(chipName);
+            exit(PS_EXIT_SYS_ERROR);
+        }
+
+        psTrace("ppMonet.read", 3, "Writing %ld rows for %s\n", size, chipName);
+        for (long j = 0; j < size; j++) {
+            psMetadata *row = table->data[j]; // Row of interest
+
+            float x = psMetadataLookupF32(NULL, row, "X_PSF");
+            float y = psMetadataLookupF32(NULL, row, "Y_PSF");
+            double ra = DEG_TO_RAD(psMetadataLookupF64(NULL, row, "RA_PSF"));
+            double dec = DEG_TO_RAD(psMetadataLookupF64(NULL, row, "DEC_PSF"));
+            float mag = psMetadataLookupF32(NULL, row, "PSF_INST_MAG");
+            float magErr = psMetadataLookupF32(NULL, row, "PSF_INST_MAG_SIG");
+            float major = psMetadataLookupF32(NULL, row, "PSF_MAJOR");
+            float minor = psMetadataLookupF32(NULL, row, "PSF_MINOR");
+            psU32 flags = psMetadataLookupU32(NULL, row, "FLAGS");
+
+            float width = 0.5 * (major + minor);
+
+            gzprintf(gz, "%s , %.2f , %.2f , %.8lf , %.8lf , %.3f , %.3f , %.2f , %#08x\n",
+                     chipName, x, y, ra, dec, mag, magErr, width, flags);
+        }
+        psFree(table);
+        psFree(chipName);
+    }
+    gzclose(gz);
+    psFitsClose(fits);
+    psFree(filter);
+
+    psTrace("ppMonet.read", 1, "Done reading input detections\n");
+
+    psLibFinalize();
+
+    return PS_EXIT_SUCCESS;
+}
Index: trunk/ppTranslate/src/ppMonet.h
===================================================================
--- trunk/ppTranslate/src/ppMonet.h	(revision 25923)
+++ trunk/ppTranslate/src/ppMonet.h	(revision 25923)
@@ -0,0 +1,23 @@
+#ifndef PP_MOPS_H
+#define PP_MOPS_H
+
+#include <pslib.h>
+
+#define EXT_TYPE ".psf"                 // Extension type to consider
+
+// Configuration data
+typedef struct {
+    psString input;                     // Input filenames
+    psString exp_name;                  // Exposure name
+    psS64 exp_id;                       // Exposure identifier
+    psS64 chip_id;                      // Chip stage identifier
+    psS64 cam_id;                       // Camera stage identifier
+    float zp, zpErr;                    // Magnitude zero point and error
+    float rmsAstrom;                    // Astrometric solution RMS
+    psString output;                    // Output filename
+} ppMonetArguments;
+
+/// Parse arguments
+ppMonetArguments *ppMonetArgumentsParse(int argc, char *argv[]);
+
+#endif
Index: trunk/ppTranslate/src/ppMonetArguments.c
===================================================================
--- trunk/ppTranslate/src/ppMonetArguments.c	(revision 25923)
+++ trunk/ppTranslate/src/ppMonetArguments.c	(revision 25923)
@@ -0,0 +1,88 @@
+#ifdef HAVE_CONFIG_H
+#include <config.h>
+#endif
+
+#include <stdio.h>
+#include <pslib.h>
+#include <psmodules.h>
+
+#include "ppMonet.h"
+
+// Print usage information and die
+static void usage(const char *program,  // Name of the program
+                  psMetadata *arguments // Command-line arguments
+                  )
+{
+    fprintf(stderr, "\nPan-STARRS IPP-Monet detection translator\n\n");
+    fprintf(stderr, "Usage: %s INPUT_NAME OUTPUT_NAME\n", program);
+    fprintf(stderr, "\n");
+    psArgumentHelp(arguments);
+    psLibFinalize();
+    exit(PS_EXIT_CONFIG_ERROR);
+}
+
+static void monetArgumentsFree(ppMonetArguments *args)
+{
+    psFree(args->input);
+    psFree(args->exp_name);
+    psFree(args->output);
+    return;
+}
+
+ppMonetArguments *ppMonetArgumentsAlloc(void)
+{
+    ppMonetArguments *args = psAlloc(sizeof(ppMonetArguments)); // Data to return
+    psMemSetDeallocator(args, (psFreeFunc)monetArgumentsFree);
+
+    args->input = NULL;
+    args->exp_name = NULL;
+    args->exp_id = 0;
+    args->chip_id = 0;
+    args->cam_id = 0;
+    args->zp = NAN;
+    args->zpErr = NAN;
+    args->rmsAstrom = NAN;
+    args->output = NULL;
+
+    return args;
+}
+
+
+ppMonetArguments *ppMonetArgumentsParse(int argc, char *argv[])
+{
+    assert(argv);
+
+    psTrace("ppMonet.args", 1, "Parsing command-line arguments\n");
+
+    psArgumentVerbosity(&argc, argv);
+
+    psMetadata *arguments = psMetadataAlloc(); // Command-line arguments
+    psMetadataAddStr(arguments, PS_LIST_TAIL, "-exp_name", 0, "Exposure name", NULL);
+    psMetadataAddS64(arguments, PS_LIST_TAIL, "-exp_id", 0, "Exposure identifier", 0);
+    psMetadataAddS64(arguments, PS_LIST_TAIL, "-chip_id", 0, "Chip stage identifier", 0);
+    psMetadataAddS64(arguments, PS_LIST_TAIL, "-cam_id", 0, "Camera stage identifier", 0);
+    psMetadataAddF32(arguments, PS_LIST_TAIL, "-zp", 0, "Magnitude zero point", NAN);
+    psMetadataAddF32(arguments, PS_LIST_TAIL, "-zp_error", 0, "Error in magnitude zero point", NAN);
+    psMetadataAddF32(arguments, PS_LIST_TAIL, "-astrom_rms", 0, "Astrometric solution RMS", NAN);
+
+    if (argc == 1 || !psArgumentParse(arguments, &argc, argv) || argc != 3) {
+        usage(argv[0], arguments);
+    }
+
+    ppMonetArguments *args = ppMonetArgumentsAlloc(); // Arguments, to return
+
+    args->input = psStringCopy(argv[1]);
+    args->output = psStringCopy(argv[2]);
+
+    args->exp_name = psMetadataLookupStr(NULL, arguments, "-exp_name");
+    args->exp_id = psMetadataLookupS64(NULL, arguments, "-exp_id");
+    args->chip_id = psMetadataLookupS64(NULL, arguments, "-chip_id");
+    args->cam_id = psMetadataLookupS64(NULL, arguments, "-cam_id");
+    args->zp = psMetadataLookupF32(NULL, arguments, "-zp");
+    args->zpErr = psMetadataLookupF32(NULL, arguments, "-zp_error");
+    args->rmsAstrom = psMetadataLookupF32(NULL, arguments, "-astrom_rms");
+
+    psTrace("ppMonet.args", 1, "Done parsing command-line arguments\n");
+
+    return args;
+}
Index: trunk/ppTranslate/src/ppMonetMerge.c
===================================================================
--- trunk/ppTranslate/src/ppMonetMerge.c	(revision 25923)
+++ trunk/ppTranslate/src/ppMonetMerge.c	(revision 25923)
@@ -0,0 +1,171 @@
+#ifdef HAVE_CONFIG_H
+#include <config.h>
+#endif
+
+#include <stdio.h>
+#include <pslib.h>
+#include <string.h>
+
+#include "ppMonet.h"
+
+#define LEAF_SIZE 4                     // Size of leaf
+#define MATCH_RADIUS SEC_TO_RAD(1.0)    // Matching radius
+#define MJD_TOL 1.0/3600.0/24.0         // Tolerance for MJD matching
+#define BORESIGHT_TOL SEC_TO_RAD(1.0)   // Tolerance for boresight matching
+#define EXPTIME_TOL 1.0e-3              // Tolerance for exposure time matching
+#define POSANGLE_TOL SEC_TO_RAD(1.0)    // Tolerance for position angle matching
+#define AIRMASS_TOL 1.0e-3              // Tolerance for airmass matching
+
+// Get distance from detection to centre of image
+static float mergeDistance(const ppMonetDetections *detections, // Detections of interest
+                           long index                          // Index for source of interest
+    )
+{
+    float dx = detections->x->data.F32[index] - detections->naxis1->data.S32[index] / 2.0;
+    float dy = detections->y->data.F32[index] - detections->naxis2->data.S32[index] / 2.0;
+    return PS_SQR(dx) + PS_SQR(dy);
+}
+
+
+ppMonetDetections *ppMonetMerge(const psArray *detections)
+{
+    PS_ASSERT_ARRAY_NON_NULL(detections, NULL);
+
+    psTrace("ppMonet.merge", 1, "Merging detections from %ld inputs\n", detections->n);
+
+    ppMonetDetections *merged = NULL;    // Merged list
+    int num = 1;                                                         // Number of merged files
+    for (int i = 0; i < detections->n; i++) {
+        ppMonetDetections *det = detections->data[i]; // Detections of interest
+        if (!det) {
+            psTrace("ppMonet.merge", 3, "Ignoring NULL input %d\n", i);
+            continue;
+        } else if (det->num == 0) {
+            psTrace("ppMonet.merge", 3, "Ignoring empty input %d\n", i);
+            continue;
+        }
+        num++;
+        if (!merged) {
+            psTrace("ppMonet.merge", 3, "Accepting %ld detections from input %d\n", det->num, i);
+            merged = psMemIncrRefCounter(det);
+            continue;
+        }
+        psTrace("ppMonet.merge", 3, "Merging %ld detections from input %d\n", det->num, i);
+
+        // XXX compare exposure properties
+        if (strcmp(merged->raBoresight, det->raBoresight) != 0) {
+            psError(PS_ERR_BAD_PARAMETER_VALUE, true, "Exposure RA values differ: %s vs %s",
+                    merged->raBoresight, det->raBoresight);
+            return NULL;
+        }
+        if (strcmp(merged->decBoresight, det->decBoresight) != 0) {
+            psError(PS_ERR_BAD_PARAMETER_VALUE, true, "Exposure Dec values differ: %s vs %s",
+                    merged->decBoresight, det->decBoresight);
+            return NULL;
+        }
+        if (strcmp(merged->filter, det->filter) != 0) {
+            psError(PS_ERR_BAD_PARAMETER_VALUE, true, "Exposure filter values differ: %s vs %s",
+                    merged->filter, det->filter);
+            return NULL;
+        }
+
+        if (fabsf(merged->airmass - det->airmass) > AIRMASS_TOL) {
+            psError(PS_ERR_BAD_PARAMETER_VALUE, true, "Exposure airmass values differ: %f vs %f",
+                    merged->airmass, det->airmass);
+            return NULL;
+        }
+        if (fabsf(merged->exptime - det->exptime) > EXPTIME_TOL) {
+            psError(PS_ERR_BAD_PARAMETER_VALUE, true, "Exposure exposure time values differ: %f vs %f",
+                    merged->exptime, det->exptime);
+            return NULL;
+        }
+        if (fabs(merged->posangle - det->posangle) > POSANGLE_TOL) {
+            psError(PS_ERR_BAD_PARAMETER_VALUE, true, "Exposure position angle values differ: %f vs %f",
+                    merged->posangle, det->posangle);
+            return NULL;
+        }
+        if (fabs(merged->alt - det->alt) > BORESIGHT_TOL) {
+            psError(PS_ERR_BAD_PARAMETER_VALUE, true, "Exposure altitude values differ: %lf vs %lf",
+                    merged->alt, det->alt);
+            return NULL;
+        }
+        if (fabs(merged->az - det->az) > BORESIGHT_TOL) {
+            psError(PS_ERR_BAD_PARAMETER_VALUE, true, "Exposure azimuth values differ: %lf vs %lf",
+                    merged->az, det->az);
+            return NULL;
+        }
+        if (fabs(merged->mjd - det->mjd) > MJD_TOL) {
+            psError(PS_ERR_BAD_PARAMETER_VALUE, true, "Exposure MJD values differ: %lf vs %lf",
+                    merged->mjd, det->mjd);
+            return NULL;
+        }
+
+        merged->seeing += det->seeing;  // Taking average
+
+        psTree *tree = psTreePlant(2, LEAF_SIZE, PS_TREE_SPHERICAL, merged->ra, merged->dec); // kd tree
+        if (!tree) {
+            psError(PS_ERR_UNEXPECTED_NULL, false, "Unable to generate kd tree");
+            psFree(merged);
+            return NULL;
+        }
+
+        psVector *coords = psVectorAlloc(2, PS_TYPE_F64); // Coordinates of interest
+        for (int j = 0; j < det->num; j++) {
+            coords->data.F64[0] = det->ra->data.F64[j];
+            coords->data.F64[1] = det->dec->data.F64[j];
+            psVector *indices = psTreeAllWithin(tree, coords, MATCH_RADIUS); // Indices for matching sources
+            if (!indices) {
+                psError(PS_ERR_UNEXPECTED_NULL, false, "Unable to search for matches");
+                psFree(coords);
+                psFree(tree);
+                psFree(merged);
+                return NULL;
+            }
+            if (indices->n == 0) {
+                psTrace("ppMonet.merge", 9, "No matches for source %d in input %d\n", j, i);
+                psFree(indices);
+                ppMonetDetectionsCopySingle(merged, det, j);
+                continue;
+            }
+            psTrace("ppMonet.merge", 5, "%ld matches for source %d from input %d\n", indices->n, j, i);
+
+            // Which one do we keep?
+            float bestDistance = INFINITY; // Best distance to centre
+            long bestIndex = -1;           // Index with best distance
+            for (int k = 0; k < indices->n; k++) {
+                long index = indices->data.S64[k]; // Index of point
+                float distance = mergeDistance(merged, index); // Distance to centre of image
+                if (distance < bestDistance) {
+                    bestDistance = distance;
+                    bestIndex = index;
+                }
+            }
+
+            float distance = mergeDistance(det, j); // Distance to centre of image
+            if (distance < bestDistance) {
+                psTrace("ppMonet.merge", 6, "New source clobbers old sources\n");
+                // Blow away existing sources
+                for (int k = 0; k < indices->n; k++) {
+                    long index = indices->data.S64[k]; // Index of point
+                    merged->mask->data.U8[index] = 0xFF;
+                }
+                ppMonetDetectionsCopySingle(merged, det, j);
+            } else {
+                psTrace("ppMonet.merge", 6, "Old sources clobber new source\n");
+            }
+            psFree(indices);
+        }
+
+        psTrace("ppMonet.merge", 3, "Done merging input %d, %ld merged sources\n", i, merged->num);
+
+        psFree(tree);
+        ppMonetDetectionsPurge(merged);
+    }
+
+    psTrace("ppMonet.merge", 2, "%ld sources in merged detections list\n", merged->num);
+
+    merged->seeing /= num;
+
+    return merged;
+}
+
Index: trunk/ppTranslate/src/ppMops.c
===================================================================
--- trunk/ppTranslate/src/ppMops.c	(revision 25923)
+++ trunk/ppTranslate/src/ppMops.c	(revision 25923)
@@ -0,0 +1,234 @@
+#include <stdio.h>
+#include <pslib.h>
+
+#include "ppMops.h"
+
+int main(int argc, char *argv[])
+{
+    psLibInit(NULL);
+
+    ppMopsArguments *args = ppMopsArgumentsParse(argc, argv); // Parsed arguments
+    if (!args) {
+        psErrorStackPrint(stderr, "Error parsing arguments");
+        exit(PS_EXIT_CONFIG_ERROR);
+    }
+
+    psArray *detections = ppMopsRead(args); // Detections from each input
+    if (!detections) {
+        psErrorStackPrint(stderr, "Unable to read detections");
+        exit(PS_EXIT_SYS_ERROR);
+    }
+
+    ppMopsDetections *merged = ppMopsMerge(detections); // Merged detections
+    psFree(detections);
+    if (!merged) {
+        psErrorStackPrint(stderr, "Unable to merge detections");
+        exit(PS_EXIT_SYS_ERROR);
+    }
+
+    if (!ppMopsWrite(merged, args)) {
+        psErrorStackPrint(stderr, "Unable to write detections");
+        exit(PS_EXIT_SYS_ERROR);
+    }
+
+    psFree(merged);
+    psFree(args);
+
+    psLibFinalize();
+
+    return PS_EXIT_SUCCESS;
+}
+
+
+#if 0
+    ps
+
+
+    psArray *detections = NULL;         // Detections
+    psMetadata *header = NULL;          // Header for detections
+    {
+        psFits *fits = psFitsOpen(data->detections, "r"); // FITS file
+        header = psFitsReadHeader(NULL, fits);
+        if (!header) {
+            psErrorStackPrint(stderr, "Unable to read header");
+            psFitsClose(fits);
+            psFree(data);
+            exit(PS_EXIT_DATA_ERROR);
+        }
+        if (!psFitsMoveExtName(fits, IN_EXTNAME)) {
+            psErrorStackPrint(stderr, "Unable to move to extension %s", IN_EXTNAME);
+            psFitsClose(fits);
+            psFree(header);
+            psFree(data);
+            exit(PS_EXIT_DATA_ERROR);
+        }
+        detections = psFitsReadTable(fits);
+        psFitsClose(fits);
+        if (!detections) {
+            psErrorStackPrint(stderr, "Unable to read detections");
+            psFree(data);
+            psFree(header);
+            exit(PS_EXIT_DATA_ERROR);
+        }
+    }
+
+    // Translate the columns
+    int numIn = detections->n;        // Number of rows in input
+    int numOut = 0;                   // Number of rows in output
+    psArray *output = psArrayAllocEmpty(numIn); // Output table
+    double plateScale = 0.0;                 // Average plate scale
+    for (int i = 0; i < numIn; i++) {
+        psMetadata *inRow = detections->data[i]; // Input row
+
+        double ra = psMetadataLookupF64(NULL, inRow, "RA_PSF");
+        double dec = psMetadataLookupF64(NULL, inRow, "DEC_PSF");
+        double mag = psMetadataLookupF64(NULL, inRow, "PSF_INST_MAG") + data->zp;
+        double magErr = psMetadataLookupF64(NULL, inRow, "PSF_INST_MAG_SIG");
+        float ext = psMetadataLookupF32(NULL, inRow, "EXT_NSIGMA");
+        double xErr = psMetadataLookupF64(NULL, inRow, "X_PSF_SIG");
+        double yErr = psMetadataLookupF64(NULL, inRow, "Y_PSF_SIG");
+        double scale = psMetadataLookupF64(NULL, inRow, "PLTSCALE");
+        double angle = psMetadataLookupF64(NULL, inRow, "POSANGLE");
+        psU32 flags = psMetadataLookupU32(NULL, inRow, "FLAGS");
+
+        if (!isfinite(mag) || !isfinite(magErr) || (flags & SOURCE_MASK) ||
+            ((flags & PM_SOURCE_MODE_DEFECT) && !(flags & PM_SOURCE_MODE_MOMENTS_FAILURE))
+            ) {
+            // DEFECT can be due to bad moments
+            continue;
+        }
+
+        psMetadata *outRow = output->data[numOut] = psMetadataAlloc(); // Output row
+
+        numOut++;
+        plateScale += scale;
+
+        // XXX Not at all sure I've got the angles around the right way here...
+        double cosAngle = cos(angle), sinAngle = sin(angle);
+        double cosAngle2 = PS_SQR(cosAngle), sinAngle2 = PS_SQR(sinAngle);
+        double xErr2 = PS_SQR(xErr), yErr2 = PS_SQR(yErr);
+        double errScale = scale / 3600.0;
+        double raErr = errScale * sqrt(cosAngle2 * xErr2 + sinAngle2 * yErr2);
+        double decErr = errScale * sqrt(sinAngle2 * xErr2 + cosAngle2 * yErr2);
+
+        psMetadataAddF64(outRow, PS_LIST_TAIL, "RA_DEG", 0, "Right ascension (degrees)", ra);
+        psMetadataAddF64(outRow, PS_LIST_TAIL, "DEC_DEG", 0, "Declination (degrees)", dec);
+        psMetadataAddF64(outRow, PS_LIST_TAIL, "RA_SIG", 0, "Right ascension error (degrees)", raErr);
+        psMetadataAddF64(outRow, PS_LIST_TAIL, "DEC_SIG", 0, "Declination error (degrees)", decErr);
+        psMetadataAddF64(outRow, PS_LIST_TAIL, "MAG", 0, "Magnitude", mag);
+        psMetadataAddF64(outRow, PS_LIST_TAIL, "MAG_SIG", 0, "Magnitude error", magErr);
+        psMetadataAddU32(outRow, PS_LIST_TAIL, "FLAGS", 0, "Detection bit flags", flags);
+
+        // The below need fixing
+        psMetadataAddF64(outRow, PS_LIST_TAIL, "STARPSF", 0, "EXT_NSIGMA", ext);
+        psMetadataAddF64(outRow, PS_LIST_TAIL, "ANG", 0, "Position angle of trail (degrees)", 0.0);
+        psMetadataAddF64(outRow, PS_LIST_TAIL, "ANG_SIG", 0, "Position angle error (degrees)", 0.0);
+        psMetadataAddF64(outRow, PS_LIST_TAIL, "LEN", 0, "Length of trail (arcsec)", 0.0);
+        psMetadataAddF64(outRow, PS_LIST_TAIL, "LEN_SIG", 0, "Length error (arcsec)", 0.0);
+    }
+    output->n = numOut;
+    plateScale /= numOut;
+    psFree(detections);
+
+    // Translate the header
+    psMetadata *outHeader = psMetadataAlloc(); // Output header
+    ppMopsVersionHeader(outHeader);
+    {
+        const char *ra = psMetadataLookupStr(NULL, header, "FPA.RA");
+        const char *dec = psMetadataLookupStr(NULL, header, "FPA.DEC");
+        const char *filter = psMetadataLookupStr(NULL, header, "FPA.FILTER");
+        float airmass = psMetadataLookupF32(NULL, header, "FPA.AIRMASS");
+        float exptime = psMetadataLookupF32(NULL, header, "EXPTIME");
+        double angle = psMetadataLookupF64(NULL, header, "FPA.POSANGLE");
+        double alt = psMetadataLookupF64(NULL, header, "FPA.ALT");
+        double az = psMetadataLookupF64(NULL, header, "FPA.AZ");
+        psS64 imageid = psMetadataLookupS64(NULL, header, "IMAGEID");
+        double mjd = psMetadataLookupF64(NULL, header, "MJD-OBS") + exptime / 2.0 / 3600 / 24;
+
+        float psf = plateScale * 0.5 * (psMetadataLookupF32(NULL, header, "FWHM_MAJ") +
+                                        psMetadataLookupF32(NULL, header, "FWHM_MIN"));
+
+        psMetadataAddStr(outHeader, PS_LIST_TAIL, "RA", 0, "Right ascension of boresight", ra);
+        psMetadataAddStr(outHeader, PS_LIST_TAIL, "DEC", 0, "Declination of boresight", dec);
+        psMetadataAddF32(outHeader, PS_LIST_TAIL, "AIRMASS", 0, "Airmass of exposure", airmass);
+        psMetadataAddF64(outHeader, PS_LIST_TAIL, "MJD-OBS", 0, "MJD of exposure midpoint", mjd);
+        psMetadataAddStr(outHeader, PS_LIST_TAIL, "FILTER", 0, "Filter name", filter);
+        psMetadataAddF64(outHeader, PS_LIST_TAIL, "EXPTIME", 0, "Exposure time (sec)", exptime);
+        psMetadataAddF64(outHeader, PS_LIST_TAIL, "ROTANGLE", 0, "Rotator position angle", angle);
+        psMetadataAddF64(outHeader, PS_LIST_TAIL, "TEL_ALT", 0, "Telescope altitude", alt);
+        psMetadataAddF64(outHeader, PS_LIST_TAIL, "TEL_AZ", 0, "Telescope azimuth", az);
+        psMetadataAddS64(outHeader, PS_LIST_TAIL, "DIFFIMID", 0, "Difference image identifier", imageid);
+        psMetadataAddStr(outHeader, PS_LIST_TAIL, "FPA_ID", 0, "Exposure name", data->exp_name);
+        psMetadataAddS64(outHeader, PS_LIST_TAIL, "EXP_ID", 0, "Exposure identifier", data->exp_id);
+        psMetadataAddBool(outHeader, PS_LIST_TAIL, "POSITIVE", 0, "Positive subtraction?", data->direction);
+        psMetadataAddStr(outHeader, PS_LIST_TAIL, "OBSCODE", 0, "IAU Observatory code", OBSERVATORY_CODE);
+        psMetadataAddF32(outHeader, PS_LIST_TAIL, "STARPSF", 0, "Stellar PSF (arcsec)", psf);
+
+        // These are completely fake
+        psMetadataAddF32(outHeader, PS_LIST_TAIL, "LIMITMAG", 0, "Limiting magnitude (FAKE)", 99.0);
+        psMetadataAddF32(outHeader, PS_LIST_TAIL, "DE1", 0, "Detection efficiency (FAKE)", 0.0);
+        psMetadataAddF32(outHeader, PS_LIST_TAIL, "DE2", 0, "Detection efficiency (FAKE)", 0.0);
+        psMetadataAddF32(outHeader, PS_LIST_TAIL, "DE3", 0, "Detection efficiency (FAKE)", 0.0);
+        psMetadataAddF32(outHeader, PS_LIST_TAIL, "DE4", 0, "Detection efficiency (FAKE)", 0.0);
+        psMetadataAddF32(outHeader, PS_LIST_TAIL, "DE5", 0, "Detection efficiency (FAKE)", 0.0);
+        psMetadataAddF32(outHeader, PS_LIST_TAIL, "DE6", 0, "Detection efficiency (FAKE)", 0.0);
+        psMetadataAddF32(outHeader, PS_LIST_TAIL, "DE7", 0, "Detection efficiency (FAKE)", 0.0);
+        psMetadataAddF32(outHeader, PS_LIST_TAIL, "DE8", 0, "Detection efficiency (FAKE)", 0.0);
+        psMetadataAddF32(outHeader, PS_LIST_TAIL, "DE9", 0, "Detection efficiency (FAKE)", 0.0);
+        psMetadataAddF32(outHeader, PS_LIST_TAIL, "DE10", 0, "Detection efficiency (FAKE)", 0.0);
+    }
+    psFree(header);
+
+    // Write the new table
+    {
+        psFits *fits = psFitsOpen(data->output, "w"); // FITS file
+        if (!fits) {
+            psErrorStackPrint(stderr, "Unable to open %s for writing", data->output);
+            psFree(outHeader);
+            psFree(output);
+            psFree(data);
+            exit(PS_EXIT_SYS_ERROR);
+        }
+
+        if (numOut == 0) {
+            // Write dummy table
+            psMetadata *outRow = psMetadataAlloc(); // Dummy output row
+            psMetadataAddF64(outRow, PS_LIST_TAIL, "RA_DEG", 0, "Right ascension (degrees)", NAN);
+            psMetadataAddF64(outRow, PS_LIST_TAIL, "DEC_DEG", 0, "Declination (degrees)", NAN);
+            psMetadataAddF64(outRow, PS_LIST_TAIL, "RA_SIG", 0, "Right ascension error (degrees)", NAN);
+            psMetadataAddF64(outRow, PS_LIST_TAIL, "DEC_SIG", 0, "Declination error (degrees)", NAN);
+            psMetadataAddF64(outRow, PS_LIST_TAIL, "MAG", 0, "Magnitude", NAN);
+            psMetadataAddF64(outRow, PS_LIST_TAIL, "MAG_SIG", 0, "Magnitude error", NAN);
+            psMetadataAddU32(outRow, PS_LIST_TAIL, "FLAGS", 0, "Detection bit flags", 0);
+
+            // The below need fixing
+            psMetadataAddF64(outRow, PS_LIST_TAIL, "STARPSF", 0, "EXT_NSIGMA", NAN);
+            psMetadataAddF64(outRow, PS_LIST_TAIL, "ANG", 0, "Position angle of trail (degrees)", NAN);
+            psMetadataAddF64(outRow, PS_LIST_TAIL, "ANG_SIG", 0, "Position angle error (degrees)", NAN);
+            psMetadataAddF64(outRow, PS_LIST_TAIL, "LEN", 0, "Length of trail (arcsec)", NAN);
+            psMetadataAddF64(outRow, PS_LIST_TAIL, "LEN_SIG", 0, "Length error (arcsec)", NAN);
+            if (!psFitsWriteTableEmpty(fits, outHeader, outRow, OUT_EXTNAME)) {
+                psErrorStackPrint(stderr, "Unable to write table.");
+                psFree(outHeader);
+                psFree(output);
+                psFree(outRow);
+                psFree(data);
+                exit(PS_EXIT_SYS_ERROR);
+            }
+            psFree(outRow);
+        } else if (!psFitsWriteTable(fits, outHeader, output, OUT_EXTNAME)) {
+            psErrorStackPrint(stderr, "Unable to write table.");
+            psFree(outHeader);
+            psFree(output);
+            psFree(data);
+            exit(PS_EXIT_SYS_ERROR);
+        }
+        psFitsClose(fits);
+    }
+
+    psFree(outHeader);
+    psFree(output);
+    psFree(data);
+
+#endif
+
Index: trunk/ppTranslate/src/ppMops.h
===================================================================
--- trunk/ppTranslate/src/ppMops.h	(revision 25923)
+++ trunk/ppTranslate/src/ppMops.h	(revision 25923)
@@ -0,0 +1,73 @@
+#ifndef PP_MOPS_H
+#define PP_MOPS_H
+
+#include <pslib.h>
+#include <pmSourceMasks.h>
+
+#define IN_EXTNAME "SkyChip.psf"        // Extension name for data in input
+#define OBSERVATORY_CODE "F51"          // IAU Observatory Code
+#define OUT_EXTNAME "MOPS_TRANSIENT_DETECTIONS" // Extension name for data in output
+#define SOURCE_MASK (PM_SOURCE_MODE_FAIL | PM_SOURCE_MODE_BADPSF | PM_SOURCE_MODE_SATURATED | \
+                     PM_SOURCE_MODE_CR_LIMIT | PM_SOURCE_MODE_SKY_FAILURE) // Flags to exclude
+
+// Configuration data
+typedef struct {
+    psArray *input;                     // Input filenames
+    psString exp_name;                  // Exposure name
+    psS64 exp_id;                       // Exposure identifier
+    psS64 chip_id;                      // Chip stage identifier
+    psS64 cam_id;                       // Camera stage identifier
+    psS64 fake_id;                      // Fake stage identifier
+    psS64 warp_id;                      // Warp stage identifier
+    psS64 diff_id;                      // Diff stage identifier
+    bool positive;                      // Sense of subtraction, T=positive, F=negative
+    float zp, zpErr;                    // Magnitude zero point and error
+    float rmsAstrom;                    // Astrometric solution RMS
+    psString output;                    // Output filename
+} ppMopsArguments;
+
+/// Parse arguments
+ppMopsArguments *ppMopsArgumentsParse(int argc, char *argv[]);
+
+typedef struct {
+    psString raBoresight, decBoresight; // RA,Dec of telescope boresight
+    psString filter;                    // Filter for exposure
+    float airmass;                      // Airmass of exposure
+    float exptime;                      // Exposure time
+    double posangle;                    // Position angle
+    double alt, az;                     // Telescope altitude and azimuth
+    double mjd;                         // Modified Julian Date
+    float seeing;                       // Seeing of exposure
+    long num;                           // Number of detections
+    psVector *x, *y;                    // Image coordinates
+    psVector *ra, *dec;                 // Sky coordinates
+    psVector *raErr, *decErr;           // Error in sky coordinates
+    psVector *mag, *magErr;             // Magnitude and associated error
+    psVector *extended;                 // Measure of extendedness
+    psVector *angle, *angleErr;         // Angle of trail and associated error
+    psVector *length, *lengthErr;       // Length of trail and associated error
+    psVector *flags;                    // psphot flags
+    psVector *diffSkyfileId;            // Identifier for source image
+    psVector *naxis1, *naxis2;          // Size of image
+    psVector *mask;                     // Mask for detections
+} ppMopsDetections;
+
+ppMopsDetections *ppMopsDetectionsAlloc(long num);
+
+/// Copy a detection
+bool ppMopsDetectionsCopySingle(ppMopsDetections *target, const ppMopsDetections *source, long index);
+
+/// Purge the detections list of masked detections
+bool ppMopsDetectionsPurge(ppMopsDetections *detections);
+
+
+/// Read detections
+psArray *ppMopsRead(const ppMopsArguments *args);
+
+/// Merge detections
+ppMopsDetections *ppMopsMerge(const psArray *detections);
+
+/// Write detections
+bool ppMopsWrite(const ppMopsDetections *detections, const ppMopsArguments *args);
+
+#endif
Index: trunk/ppTranslate/src/ppMopsArguments.c
===================================================================
--- trunk/ppTranslate/src/ppMopsArguments.c	(revision 25923)
+++ trunk/ppTranslate/src/ppMopsArguments.c	(revision 25923)
@@ -0,0 +1,107 @@
+#ifdef HAVE_CONFIG_H
+#include <config.h>
+#endif
+
+#include <stdio.h>
+#include <pslib.h>
+#include <psmodules.h>
+
+#include "ppMops.h"
+
+// Print usage information and die
+static void usage(const char *program,  // Name of the program
+                  psMetadata *arguments // Command-line arguments
+                  )
+{
+    fprintf(stderr, "\nPan-STARRS IPP-MOPS detection translator\n\n");
+    fprintf(stderr, "Usage: %s INPUT_LIST OUTPUT_NAME\n", program);
+    fprintf(stderr, "\n");
+    psArgumentHelp(arguments);
+    psLibFinalize();
+    exit(PS_EXIT_CONFIG_ERROR);
+}
+
+static void mopsArgumentsFree(ppMopsArguments *args)
+{
+    psFree(args->input);
+    psFree(args->exp_name);
+    psFree(args->output);
+    return;
+}
+
+ppMopsArguments *ppMopsArgumentsAlloc(void)
+{
+    ppMopsArguments *args = psAlloc(sizeof(ppMopsArguments)); // Data to return
+    psMemSetDeallocator(args, (psFreeFunc)mopsArgumentsFree);
+
+    args->input = NULL;
+    args->exp_name = NULL;
+    args->exp_id = 0;
+    args->chip_id = 0;
+    args->cam_id = 0;
+    args->fake_id = 0;
+    args->warp_id = 0;
+    args->diff_id = 0;
+    args->zp = NAN;
+    args->positive = true;
+    args->zpErr = NAN;
+    args->rmsAstrom = NAN;
+    args->output = NULL;
+
+    return args;
+}
+
+
+ppMopsArguments *ppMopsArgumentsParse(int argc, char *argv[])
+{
+    assert(argv);
+
+    psTrace("ppMops.args", 1, "Parsing command-line arguments\n");
+
+    psArgumentVerbosity(&argc, argv);
+
+    psMetadata *arguments = psMetadataAlloc(); // Command-line arguments
+    psMetadataAddStr(arguments, PS_LIST_TAIL, "-exp_name", 0, "Exposure name", NULL);
+    psMetadataAddS64(arguments, PS_LIST_TAIL, "-exp_id", 0, "Exposure identifier", 0);
+    psMetadataAddS64(arguments, PS_LIST_TAIL, "-chip_id", 0, "Chip stage identifier", 0);
+    psMetadataAddS64(arguments, PS_LIST_TAIL, "-cam_id", 0, "Camera stage identifier", 0);
+    psMetadataAddS64(arguments, PS_LIST_TAIL, "-fake_id", 0, "Fake stage identifier", 0);
+    psMetadataAddS64(arguments, PS_LIST_TAIL, "-warp_id", 0, "Warp stage identifier", 0);
+    psMetadataAddS64(arguments, PS_LIST_TAIL, "-diff_id", 0, "Diff stage identifier", 0);
+    psMetadataAddBool(arguments, PS_LIST_TAIL, "-inverse", 0, "Inverse subtraction?", false);
+    psMetadataAddF32(arguments, PS_LIST_TAIL, "-zp", 0, "Magnitude zero point", NAN);
+    psMetadataAddF32(arguments, PS_LIST_TAIL, "-zp_error", 0, "Error in magnitude zero point", NAN);
+    psMetadataAddF32(arguments, PS_LIST_TAIL, "-astrom_rms", 0, "Astrometric solution RMS", NAN);
+
+    if (argc == 1 || !psArgumentParse(arguments, &argc, argv) || argc != 3) {
+        usage(argv[0], arguments);
+    }
+
+    ppMopsArguments *args = ppMopsArgumentsAlloc(); // Arguments, to return
+
+    psString inList = psSlurpFilename(argv[1]); // List of filenames
+    args->input = psStringSplitArray(inList, "\n", false);
+    psFree(inList);
+    if (!args->input || args->input->n == 0) {
+        psError(PS_ERR_BAD_PARAMETER_VALUE, true, "No inputs provided.");
+        return NULL;
+    }
+    args->output = psStringCopy(argv[2]);
+
+    args->exp_name = psMetadataLookupStr(NULL, arguments, "-exp_name");
+    args->exp_id = psMetadataLookupS64(NULL, arguments, "-exp_id");
+    args->chip_id = psMetadataLookupS64(NULL, arguments, "-chip_id");
+    args->cam_id = psMetadataLookupS64(NULL, arguments, "-cam_id");
+    args->fake_id = psMetadataLookupS64(NULL, arguments, "-fake_id");
+    args->warp_id = psMetadataLookupS64(NULL, arguments, "-warp_id");
+    args->diff_id = psMetadataLookupS64(NULL, arguments, "-diff_id");
+    args->positive = !psMetadataLookupBool(NULL, arguments, "-inverse"); // NOTE: negated
+
+    args->zp = psMetadataLookupF32(NULL, arguments, "-zp");
+    args->zpErr = psMetadataLookupF32(NULL, arguments, "-zp_error");
+    args->rmsAstrom = psMetadataLookupF32(NULL, arguments, "-astrom_rms");
+
+    psTrace("ppMops.args", 1, "Done parsing command-line arguments\n");
+
+    return args;
+}
Index: trunk/ppTranslate/src/ppMopsDetections.c
===================================================================
--- trunk/ppTranslate/src/ppMopsDetections.c	(revision 25923)
+++ trunk/ppTranslate/src/ppMopsDetections.c	(revision 25923)
@@ -0,0 +1,204 @@
+#ifdef HAVE_CONFIG_H
+#include <config.h>
+#endif
+
+#include <stdio.h>
+#include <pslib.h>
+
+#include "ppMops.h"
+
+static void mopsDetectionsFree(ppMopsDetections *det)
+{
+    psFree(det->raBoresight);
+    psFree(det->decBoresight);
+    psFree(det->filter);
+    psFree(det->x);
+    psFree(det->y);
+    psFree(det->ra);
+    psFree(det->dec);
+    psFree(det->raErr);
+    psFree(det->decErr);
+    psFree(det->mag);
+    psFree(det->magErr);
+    psFree(det->extended);
+    psFree(det->angle);
+    psFree(det->angleErr);
+    psFree(det->length);
+    psFree(det->lengthErr);
+    psFree(det->flags);
+    psFree(det->diffSkyfileId);
+    psFree(det->naxis1);
+    psFree(det->naxis2);
+    psFree(det->mask);
+    return;
+}
+
+ppMopsDetections *ppMopsDetectionsAlloc(long num)
+{
+    ppMopsDetections *det = psAlloc(sizeof(ppMopsDetections)); // Detections, to return
+    psMemSetDeallocator(det, (psFreeFunc)mopsDetectionsFree);
+
+    det->raBoresight = NULL;
+    det->decBoresight = NULL;
+    det->filter = NULL;
+    det->airmass = NAN;
+    det->exptime = NAN;
+    det->posangle = NAN;
+    det->alt = NAN;
+    det->az = NAN;
+    det->mjd = NAN;
+    det->seeing = NAN;
+    det->num = 0;
+    det->x = psVectorAllocEmpty(num, PS_TYPE_F32);
+    det->y = psVectorAllocEmpty(num, PS_TYPE_F32);
+    det->ra = psVectorAllocEmpty(num, PS_TYPE_F64);
+    det->dec = psVectorAllocEmpty(num, PS_TYPE_F64);
+    det->raErr = psVectorAllocEmpty(num, PS_TYPE_F64);
+    det->decErr = psVectorAllocEmpty(num, PS_TYPE_F64);
+    det->mag = psVectorAllocEmpty(num, PS_TYPE_F32);
+    det->magErr = psVectorAllocEmpty(num, PS_TYPE_F32);
+    det->extended = psVectorAllocEmpty(num, PS_TYPE_F32);
+    det->angle = psVectorAllocEmpty(num, PS_TYPE_F32);
+    det->angleErr = psVectorAllocEmpty(num, PS_TYPE_F32);
+    det->length = psVectorAllocEmpty(num, PS_TYPE_F32);
+    det->lengthErr = psVectorAllocEmpty(num, PS_TYPE_F32);
+    det->flags = psVectorAllocEmpty(num, PS_TYPE_U32);
+    det->diffSkyfileId = psVectorAllocEmpty(num, PS_TYPE_S64);
+    det->naxis1 = psVectorAllocEmpty(num, PS_TYPE_S32);
+    det->naxis2 = psVectorAllocEmpty(num, PS_TYPE_S32);
+    det->mask = psVectorAllocEmpty(num, PS_TYPE_U8);
+
+    return det;
+}
+
+
+ppMopsDetections *ppMopsDetectionsRealloc(ppMopsDetections *det, long num)
+{
+    det->x = psVectorRealloc(det->x, num);
+    det->y = psVectorRealloc(det->y, num);
+    det->ra = psVectorRealloc(det->ra, num);
+    det->dec = psVectorRealloc(det->dec, num);
+    det->raErr = psVectorRealloc(det->raErr, num);
+    det->decErr = psVectorRealloc(det->decErr, num);
+    det->mag = psVectorRealloc(det->mag, num);
+    det->magErr = psVectorRealloc(det->magErr, num);
+    det->extended = psVectorRealloc(det->extended, num);
+    det->angle = psVectorRealloc(det->angle, num);
+    det->angleErr = psVectorRealloc(det->angleErr, num);
+    det->length = psVectorRealloc(det->length, num);
+    det->lengthErr = psVectorRealloc(det->lengthErr, num);
+    det->flags = psVectorRealloc(det->flags, num);
+    det->diffSkyfileId = psVectorRealloc(det->diffSkyfileId, num);
+    det->naxis1 = psVectorRealloc(det->naxis1, num);
+    det->naxis2 = psVectorRealloc(det->naxis2, num);
+    det->mask = psVectorRealloc(det->mask, num);
+
+    return det;
+}
+
+
+bool ppMopsDetectionsAdd(ppMopsDetections *det, float x, float y, double ra, double dec,
+                         double raErr, double decErr, float mag, float magErr, float extended,
+                         float angle, float angleErr, float length, float lengthErr,
+                         psU32 flags, psS64 diffSkyfileId, int naxis1, int naxis2)
+{
+    psVectorAppend(det->x, x);
+    psVectorAppend(det->y, y);
+    psVectorAppend(det->ra, ra);
+    psVectorAppend(det->dec, dec);
+    psVectorAppend(det->raErr, raErr);
+    psVectorAppend(det->decErr, decErr);
+    psVectorAppend(det->mag, mag);
+    psVectorAppend(det->magErr, magErr);
+    psVectorAppend(det->extended, extended);
+    psVectorAppend(det->angle, angle);
+    psVectorAppend(det->angleErr, angleErr);
+    psVectorAppend(det->length, length);
+    psVectorAppend(det->lengthErr, lengthErr);
+    psVectorAppend(det->flags, flags);
+    psVectorAppend(det->diffSkyfileId, diffSkyfileId);
+    psVectorAppend(det->naxis1, naxis1);
+    psVectorAppend(det->naxis2, naxis2);
+    psVectorAppend(det->mask, 0);
+    return true;
+}
+
+
+bool ppMopsDetectionsCopySingle(ppMopsDetections *target, const ppMopsDetections *source, long index)
+{
+    psVectorAppend(target->x, source->x->data.F32[index]);
+    psVectorAppend(target->y, source->y->data.F32[index]);
+    psVectorAppend(target->ra, source->ra->data.F64[index]);
+    psVectorAppend(target->dec, source->dec->data.F64[index]);
+    psVectorAppend(target->raErr, source->raErr->data.F64[index]);
+    psVectorAppend(target->decErr, source->decErr->data.F64[index]);
+    psVectorAppend(target->mag, source->mag->data.F32[index]);
+    psVectorAppend(target->magErr, source->magErr->data.F32[index]);
+    psVectorAppend(target->extended, source->extended->data.F32[index]);
+    psVectorAppend(target->angle, source->angle->data.F32[index]);
+    psVectorAppend(target->angleErr, source->angleErr->data.F32[index]);
+    psVectorAppend(target->length, source->length->data.F32[index]);
+    psVectorAppend(target->lengthErr, source->lengthErr->data.F32[index]);
+    psVectorAppend(target->flags, source->flags->data.U32[index]);
+    psVectorAppend(target->diffSkyfileId, source->diffSkyfileId->data.S64[index]);
+    psVectorAppend(target->naxis1, source->naxis1->data.S32[index]);
+    psVectorAppend(target->naxis2, source->naxis2->data.S32[index]);
+    psVectorAppend(target->mask, 0);
+    target->num++;
+    return true;
+}
+
+
+bool ppMopsDetectionsPurge(ppMopsDetections *det)
+{
+    long num = 0;
+    for (long i = 0; i < det->num; i++) {
+        if (!det->mask->data.U8[i]) {
+            if (i == num) {
+                // No need to copy
+                num++;
+                continue;
+            }
+            det->x->data.F32[num] = det->x->data.F32[i];
+            det->y->data.F32[num] = det->y->data.F32[i];
+            det->ra->data.F64[num] = det->ra->data.F64[i];
+            det->dec->data.F64[num] = det->dec->data.F64[i];
+            det->raErr->data.F64[num] = det->raErr->data.F64[i];
+            det->decErr->data.F64[num] = det->decErr->data.F64[i];
+            det->mag->data.F32[num] = det->mag->data.F32[i];
+            det->magErr->data.F32[num] = det->magErr->data.F32[i];
+            det->extended->data.F32[num] = det->extended->data.F32[i];
+            det->angle->data.F32[num] = det->angle->data.F32[i];
+            det->angleErr->data.F32[num] = det->angleErr->data.F32[i];
+            det->length->data.F32[num] = det->length->data.F32[i];
+            det->lengthErr->data.F32[num] = det->lengthErr->data.F32[i];
+            det->flags->data.U32[num] = det->flags->data.U32[i];
+            det->diffSkyfileId->data.S64[num] = det->diffSkyfileId->data.S64[i];
+            det->naxis1->data.S32[num] = det->naxis1->data.S32[i];
+            det->naxis2->data.S32[num] = det->naxis2->data.S32[i];
+            det->mask->data.U8[num] = 0;
+            num++;
+        }
+    }
+    det->x->n = num;
+    det->y->n = num;
+    det->ra->n = num;
+    det->dec->n = num;
+    det->raErr->n = num;
+    det->decErr->n = num;
+    det->mag->n = num;
+    det->magErr->n = num;
+    det->extended->n = num;
+    det->angle->n = num;
+    det->angleErr->n = num;
+    det->length->n = num;
+    det->lengthErr->n = num;
+    det->flags->n = num;
+    det->diffSkyfileId->n = num;
+    det->naxis1->n = num;
+    det->naxis2->n = num;
+    det->mask->n = num;
+    det->num = num;
+    return true;
+}
+
Index: trunk/ppTranslate/src/ppMopsMerge.c
===================================================================
--- trunk/ppTranslate/src/ppMopsMerge.c	(revision 25923)
+++ trunk/ppTranslate/src/ppMopsMerge.c	(revision 25923)
@@ -0,0 +1,171 @@
+#ifdef HAVE_CONFIG_H
+#include <config.h>
+#endif
+
+#include <stdio.h>
+#include <pslib.h>
+#include <string.h>
+
+#include "ppMops.h"
+
+#define LEAF_SIZE 4                     // Size of leaf
+#define MATCH_RADIUS SEC_TO_RAD(1.0)    // Matching radius
+#define MJD_TOL 1.0/3600.0/24.0         // Tolerance for MJD matching
+#define BORESIGHT_TOL SEC_TO_RAD(1.0)   // Tolerance for boresight matching
+#define EXPTIME_TOL 1.0e-3              // Tolerance for exposure time matching
+#define POSANGLE_TOL SEC_TO_RAD(1.0)    // Tolerance for position angle matching
+#define AIRMASS_TOL 1.0e-3              // Tolerance for airmass matching
+
+// Get distance from detection to centre of image
+static float mergeDistance(const ppMopsDetections *detections, // Detections of interest
+                           long index                          // Index for source of interest
+    )
+{
+    float dx = detections->x->data.F32[index] - detections->naxis1->data.S32[index] / 2.0;
+    float dy = detections->y->data.F32[index] - detections->naxis2->data.S32[index] / 2.0;
+    return PS_SQR(dx) + PS_SQR(dy);
+}
+
+
+ppMopsDetections *ppMopsMerge(const psArray *detections)
+{
+    PS_ASSERT_ARRAY_NON_NULL(detections, NULL);
+
+    psTrace("ppMops.merge", 1, "Merging detections from %ld inputs\n", detections->n);
+
+    ppMopsDetections *merged = NULL;    // Merged list
+    int num = 1;                                                         // Number of merged files
+    for (int i = 0; i < detections->n; i++) {
+        ppMopsDetections *det = detections->data[i]; // Detections of interest
+        if (!det) {
+            psTrace("ppMops.merge", 3, "Ignoring NULL input %d\n", i);
+            continue;
+        } else if (det->num == 0) {
+            psTrace("ppMops.merge", 3, "Ignoring empty input %d\n", i);
+            continue;
+        }
+        num++;
+        if (!merged) {
+            psTrace("ppMops.merge", 3, "Accepting %ld detections from input %d\n", det->num, i);
+            merged = psMemIncrRefCounter(det);
+            continue;
+        }
+        psTrace("ppMops.merge", 3, "Merging %ld detections from input %d\n", det->num, i);
+
+        // XXX compare exposure properties
+        if (strcmp(merged->raBoresight, det->raBoresight) != 0) {
+            psError(PS_ERR_BAD_PARAMETER_VALUE, true, "Exposure RA values differ: %s vs %s",
+                    merged->raBoresight, det->raBoresight);
+            return NULL;
+        }
+        if (strcmp(merged->decBoresight, det->decBoresight) != 0) {
+            psError(PS_ERR_BAD_PARAMETER_VALUE, true, "Exposure Dec values differ: %s vs %s",
+                    merged->decBoresight, det->decBoresight);
+            return NULL;
+        }
+        if (strcmp(merged->filter, det->filter) != 0) {
+            psError(PS_ERR_BAD_PARAMETER_VALUE, true, "Exposure filter values differ: %s vs %s",
+                    merged->filter, det->filter);
+            return NULL;
+        }
+
+        if (fabsf(merged->airmass - det->airmass) > AIRMASS_TOL) {
+            psError(PS_ERR_BAD_PARAMETER_VALUE, true, "Exposure airmass values differ: %f vs %f",
+                    merged->airmass, det->airmass);
+            return NULL;
+        }
+        if (fabsf(merged->exptime - det->exptime) > EXPTIME_TOL) {
+            psError(PS_ERR_BAD_PARAMETER_VALUE, true, "Exposure exposure time values differ: %f vs %f",
+                    merged->exptime, det->exptime);
+            return NULL;
+        }
+        if (fabs(merged->posangle - det->posangle) > POSANGLE_TOL) {
+            psError(PS_ERR_BAD_PARAMETER_VALUE, true, "Exposure position angle values differ: %f vs %f",
+                    merged->posangle, det->posangle);
+            return NULL;
+        }
+        if (fabs(merged->alt - det->alt) > BORESIGHT_TOL) {
+            psError(PS_ERR_BAD_PARAMETER_VALUE, true, "Exposure altitude values differ: %lf vs %lf",
+                    merged->alt, det->alt);
+            return NULL;
+        }
+        if (fabs(merged->az - det->az) > BORESIGHT_TOL) {
+            psError(PS_ERR_BAD_PARAMETER_VALUE, true, "Exposure azimuth values differ: %lf vs %lf",
+                    merged->az, det->az);
+            return NULL;
+        }
+        if (fabs(merged->mjd - det->mjd) > MJD_TOL) {
+            psError(PS_ERR_BAD_PARAMETER_VALUE, true, "Exposure MJD values differ: %lf vs %lf",
+                    merged->mjd, det->mjd);
+            return NULL;
+        }
+
+        merged->seeing += det->seeing;  // Taking average
+
+        psTree *tree = psTreePlant(2, LEAF_SIZE, PS_TREE_SPHERICAL, merged->ra, merged->dec); // kd tree
+        if (!tree) {
+            psError(PS_ERR_UNEXPECTED_NULL, false, "Unable to generate kd tree");
+            psFree(merged);
+            return NULL;
+        }
+
+        psVector *coords = psVectorAlloc(2, PS_TYPE_F64); // Coordinates of interest
+        for (int j = 0; j < det->num; j++) {
+            coords->data.F64[0] = det->ra->data.F64[j];
+            coords->data.F64[1] = det->dec->data.F64[j];
+            psVector *indices = psTreeAllWithin(tree, coords, MATCH_RADIUS); // Indices for matching sources
+            if (!indices) {
+                psError(PS_ERR_UNEXPECTED_NULL, false, "Unable to search for matches");
+                psFree(coords);
+                psFree(tree);
+                psFree(merged);
+                return NULL;
+            }
+            if (indices->n == 0) {
+                psTrace("ppMops.merge", 9, "No matches for source %d in input %d\n", j, i);
+                psFree(indices);
+                ppMopsDetectionsCopySingle(merged, det, j);
+                continue;
+            }
+            psTrace("ppMops.merge", 5, "%ld matches for source %d from input %d\n", indices->n, j, i);
+
+            // Which one do we keep?
+            float bestDistance = INFINITY; // Best distance to centre
+            long bestIndex = -1;           // Index with best distance
+            for (int k = 0; k < indices->n; k++) {
+                long index = indices->data.S64[k]; // Index of point
+                float distance = mergeDistance(merged, index); // Distance to centre of image
+                if (distance < bestDistance) {
+                    bestDistance = distance;
+                    bestIndex = index;
+                }
+            }
+
+            float distance = mergeDistance(det, j); // Distance to centre of image
+            if (distance < bestDistance) {
+                psTrace("ppMops.merge", 6, "New source clobbers old sources\n");
+                // Blow away existing sources
+                for (int k = 0; k < indices->n; k++) {
+                    long index = indices->data.S64[k]; // Index of point
+                    merged->mask->data.U8[index] = 0xFF;
+                }
+                ppMopsDetectionsCopySingle(merged, det, j);
+            } else {
+                psTrace("ppMops.merge", 6, "Old sources clobber new source\n");
+            }
+            psFree(indices);
+        }
+
+        psTrace("ppMops.merge", 3, "Done merging input %d, %ld merged sources\n", i, merged->num);
+
+        psFree(tree);
+        ppMopsDetectionsPurge(merged);
+    }
+
+    psTrace("ppMops.merge", 2, "%ld sources in merged detections list\n", merged->num);
+
+    merged->seeing /= num;
+
+    return merged;
+}
+
Index: trunk/ppTranslate/src/ppMopsRead.c
===================================================================
--- trunk/ppTranslate/src/ppMopsRead.c	(revision 25923)
+++ trunk/ppTranslate/src/ppMopsRead.c	(revision 25923)
@@ -0,0 +1,166 @@
+#ifdef HAVE_CONFIG_H
+#include <config.h>
+#endif
+
+#include <stdio.h>
+#include <pslib.h>
+
+#include "ppMops.h"
+
+psArray *ppMopsRead(const ppMopsArguments *args)
+{
+    psTrace("ppMops.read", 1, "Reading input detections\n");
+
+    psArray *inNames = args->input;          // Input names
+    long num = inNames->n;                   // Number of inputs
+    psArray *detections = psArrayAlloc(num); // Array of detections, to return
+    for (int i = 0; i < num; i++) {
+        psFits *fits = psFitsOpen(inNames->data[i], "r"); // FITS file
+        if (!fits) {
+            psError(PS_ERR_IO, false, "Unable to open input %d", i);
+            return false;
+        }
+        psMetadata *header = psFitsReadHeader(NULL, fits); // Primary header
+        if (!header) {
+            psError(PS_ERR_IO, false, "Unable to read header %d", i);
+            return false;
+        }
+
+        psS64 diffSkyfileId = psMetadataLookupS64(NULL, header, "IMAGEID"); // Identifier for image
+        if (diffSkyfileId == 0) {
+            psError(PS_ERR_UNEXPECTED_NULL, false, "Unable to find identifier for image %d", i);
+            return false;
+        }
+
+        if (!psFitsMoveExtName(fits, "SkyChip.psf")) {
+            psError(PS_ERR_IO, false, "Unable to move to HDU with detections");
+            return false;
+        }
+
+        long size = psFitsTableSize(fits); // Size of table
+        if (size <= 0) {
+            psErrorStackPrint(stderr, "Unable to determine size of table %d", i);
+            psErrorClear();
+            psWarning("Ignoring input %d", i);
+            psFree(header);
+            psFitsClose(fits);
+            continue;
+        }
+        ppMopsDetections *det = ppMopsDetectionsAlloc(size);
+
+        psTrace("ppMops.read", 3, "Reading %ld rows from %s\n", size, (const char*)inNames->data[i]);
+
+        det->raBoresight = psMemIncrRefCounter(psMetadataLookupStr(NULL, header, "FPA.RA"));
+        det->decBoresight = psMemIncrRefCounter(psMetadataLookupStr(NULL, header, "FPA.DEC"));
+        det->filter = psMemIncrRefCounter(psMetadataLookupStr(NULL, header, "FPA.FILTER"));
+        det->airmass = psMetadataLookupF32(NULL, header, "AIRMASS");
+        det->exptime = psMetadataLookupF32(NULL, header, "EXPTIME");
+        det->posangle = psMetadataLookupF64(NULL, header, "FPA.POSANGLE");
+        det->alt = psMetadataLookupF64(NULL, header, "FPA.ALT");
+        det->az = psMetadataLookupF64(NULL, header, "FPA.AZ");
+        det->mjd = psMetadataLookupF64(NULL, header, "MJD-OBS") + det->exptime / 2.0 / 3600 / 24;
+
+        det->seeing = 0.5 * (psMetadataLookupF32(NULL, header, "FWHM_MAJ") +
+                             psMetadataLookupF32(NULL, header, "FWHM_MIN"));
+
+        int naxis1 = psMetadataLookupS32(NULL, header, "IMNAXIS1"); // Number of columns
+        int naxis2 = psMetadataLookupS32(NULL, header, "IMNAXIS2"); // Number of rows
+
+        psFree(header);
+
+        psArray *table = psFitsReadTable(fits); // Table of interest
+        if (!table) {
+            psError(PS_ERR_IO, false, "Unable to read table %d", i);
+            return false;
+        }
+        psFitsClose(fits);
+
+        double plateScale = 0.0;        // Plate scale
+        long numGood = 0;               // Number of good rows
+        for (long j = 0; j < size; j++) {
+            psMetadata *row = table->data[j]; // Row of interest
+
+            psU32 flags = psMetadataLookupU32(NULL, row, "FLAGS");
+            if (flags & SOURCE_MASK) {
+                continue;
+            }
+
+            det->x->data.F32[numGood] = psMetadataLookupF32(NULL, row, "X_PSF");
+            det->y->data.F32[numGood] = psMetadataLookupF32(NULL, row, "Y_PSF");
+            det->ra->data.F64[numGood] = DEG_TO_RAD(psMetadataLookupF64(NULL, row, "RA_PSF"));
+            det->dec->data.F64[numGood] = DEG_TO_RAD(psMetadataLookupF64(NULL, row, "DEC_PSF"));
+            det->mag->data.F32[numGood] = psMetadataLookupF32(NULL, row, "PSF_INST_MAG");
+            det->magErr->data.F32[numGood] = psMetadataLookupF32(NULL, row, "PSF_INST_MAG_SIG");
+            det->extended->data.F32[numGood] = psMetadataLookupF32(NULL, row, "EXT_NSIGMA");
+            det->angle->data.F32[numGood] = 0.0;
+            det->angleErr->data.F32[numGood] = 0.0;
+            det->length->data.F32[numGood] = 0.0;
+            det->lengthErr->data.F32[numGood] = 0.0;
+            det->flags->data.U32[numGood] = psMetadataLookupU32(NULL, row, "FLAGS");
+            det->diffSkyfileId->data.S64[numGood] = diffSkyfileId;
+            det->naxis1->data.S32[numGood] = naxis1;
+            det->naxis2->data.S32[numGood] = naxis2;
+
+            // Calculate error in RA, Dec
+            double xErr = psMetadataLookupF64(NULL, row, "X_PSF_SIG");
+            double yErr = psMetadataLookupF64(NULL, row, "Y_PSF_SIG");
+            double scale = psMetadataLookupF64(NULL, row, "PLTSCALE");
+            double angle = psMetadataLookupF64(NULL, row, "POSANGLE");
+
+            if (!isfinite(det->x->data.F32[numGood]) || !isfinite(det->y->data.F32[numGood]) ||
+                !isfinite(det->ra->data.F64[numGood]) || !isfinite(det->dec->data.F64[numGood]) ||
+                !isfinite(det->mag->data.F32[numGood]) || !isfinite(det->magErr->data.F32[numGood]) ||
+                !isfinite(xErr) || !isfinite(yErr) || !isfinite(scale) || !isfinite(angle) ||
+                (det->flags->data.U32[numGood] & SOURCE_MASK)) {
+                continue;
+            }
+
+            // XXX Not at all sure I've got the angles around the right way here...
+            double cosAngle = cos(angle), sinAngle = sin(angle);
+            double cosAngle2 = PS_SQR(cosAngle), sinAngle2 = PS_SQR(sinAngle);
+            double xErr2 = PS_SQR(xErr), yErr2 = PS_SQR(yErr);
+            double errScale = scale / 3600.0;
+            det->raErr->data.F64[numGood] = errScale * sqrt(cosAngle2 * xErr2 + sinAngle2 * yErr2);
+            det->decErr->data.F64[numGood] = errScale * sqrt(sinAngle2 * xErr2 + cosAngle2 * yErr2);
+
+            det->mask->data.U8[numGood] = 0;
+            plateScale += scale;
+            numGood++;
+        }
+        det->seeing *= plateScale / numGood;
+
+        det->x->n = numGood;
+        det->y->n = numGood;
+        det->ra->n = numGood;
+        det->dec->n = numGood;
+        det->raErr->n = numGood;
+        det->decErr->n = numGood;
+        det->mag->n = numGood;
+        det->magErr->n = numGood;
+        det->extended->n = numGood;
+        det->angle->n = numGood;
+        det->angleErr->n = numGood;
+        det->length->n = numGood;
+        det->lengthErr->n = numGood;
+        det->flags->n = numGood;
+        det->diffSkyfileId->n = numGood;
+        det->naxis1->n = numGood;
+        det->naxis2->n = numGood;
+        det->mask->n = numGood;
+
+        det->num = numGood;
+
+        if (isfinite(args->zp) && numGood > 0) {
+            psBinaryOp(det->mag, det->mag, "+", psScalarAlloc(args->zp, PS_TYPE_F32));
+        }
+
+        psTrace("ppMops.read", 2, "Read %ld good rows from %s\n", numGood, (const char*)inNames->data[i]);
+
+        psFree(table);
+        detections->data[i] = det;
+    }
+
+    psTrace("ppMops.read", 1, "Done reading input detections\n");
+
+    return detections;
+}
Index: trunk/ppTranslate/src/ppMopsWrite.c
===================================================================
--- trunk/ppTranslate/src/ppMopsWrite.c	(revision 25923)
+++ trunk/ppTranslate/src/ppMopsWrite.c	(revision 25923)
@@ -0,0 +1,120 @@
+#ifdef HAVE_CONFIG_H
+#include <config.h>
+#endif
+
+#include <stdio.h>
+#include <pslib.h>
+
+#include "ppMops.h"
+#include "ppTranslateVersion.h"
+
+bool ppMopsWrite(const ppMopsDetections *det, const ppMopsArguments *args)
+{
+    psTrace("ppMops.write", 1, "Writing %ld rows to %s", det->num, args->output);
+
+    psFits *fits = psFitsOpen(args->output, "w"); // FITS file
+    if (!fits) {
+        psError(PS_ERR_IO, false, "Unable to open output file.");
+        return false;
+    }
+
+
+    psMetadata *header = psMetadataAlloc(); // Header to write
+    psString source = ppTranslateSource(), version = ppTranslateVersion();
+    psMetadataAddStr(header, PS_LIST_TAIL, "SWSOURCE", 0, "Software source", source);
+    psMetadataAddStr(header, PS_LIST_TAIL, "SWVERSN", 0, "Software version", version);
+    ppTranslateVersionHeader(header);
+    psFree(source);
+    psFree(version);
+
+    psMetadataAddStr(header, PS_LIST_TAIL, "EXP_NAME", 0, "Exposure name", args->exp_name);
+    psMetadataAddS64(header, PS_LIST_TAIL, "EXP_ID", 0, "Exposure identifier", args->exp_id);
+    psMetadataAddS64(header, PS_LIST_TAIL, "CHIP_ID", 0, "Chip stage identifier", args->chip_id);
+    psMetadataAddS64(header, PS_LIST_TAIL, "CAM_ID", 0, "Cam stage identifier", args->cam_id);
+    psMetadataAddS64(header, PS_LIST_TAIL, "FAKE_ID", 0, "Fake stage identifier", args->fake_id);
+    psMetadataAddS64(header, PS_LIST_TAIL, "WARP_ID", 0, "Warp stage identifier", args->warp_id);
+    psMetadataAddS64(header, PS_LIST_TAIL, "DIFF_ID", 0, "Diff stage identifier", args->diff_id);
+    psMetadataAddBool(header, PS_LIST_TAIL, "DIFF_POS", 0, "Positive subtraction?", args->positive);
+
+    psMetadataAddF64(header, PS_LIST_TAIL, "MJD-OBS", 0, "MJD of exposure midpoint", det->mjd);
+    psMetadataAddStr(header, PS_LIST_TAIL, "RA", 0, "Right Ascension of boresight", det->raBoresight);
+    psMetadataAddStr(header, PS_LIST_TAIL, "DEC", 0, "Declination of boresight", det->decBoresight);
+    psMetadataAddF64(header, PS_LIST_TAIL, "TEL_ALT", 0, "Telescope altitude", det->alt);
+    psMetadataAddF64(header, PS_LIST_TAIL, "TEL_AZ", 0, "Telescope azimuth", det->az);
+    psMetadataAddF64(header, PS_LIST_TAIL, "EXPTIME", 0, "Exposure time (sec)", det->exptime);
+    psMetadataAddF64(header, PS_LIST_TAIL, "ROTANGLE", 0, "Rotator position angle", det->posangle);
+    psMetadataAddStr(header, PS_LIST_TAIL, "FILTER", 0, "Filter name", det->filter);
+    psMetadataAddF32(header, PS_LIST_TAIL, "AIRMASS", 0, "Airmass of exposure", det->airmass);
+    psMetadataAddStr(header, PS_LIST_TAIL, "OBSCODE", 0, "IAU Observatory code", OBSERVATORY_CODE);
+    psMetadataAddF32(header, PS_LIST_TAIL, "SEEING", 0, "Mean seeing", det->seeing);
+    psMetadataAddF32(header, PS_LIST_TAIL, "MAGZP", 0, "Magnitude zero point", args->zp);
+    psMetadataAddF32(header, PS_LIST_TAIL, "MAGZPERR", 0, "Error in magnitude zero point", args->zpErr);
+    psMetadataAddF32(header, PS_LIST_TAIL, "ASTRORMS", 0, "RMS of astrometric fit", args->rmsAstrom);
+
+    if (det->num == 0) {
+        // Write dummy table
+        psMetadata *row = psMetadataAlloc(); // Output row
+        psMetadataAddF64(row, PS_LIST_TAIL, "RA", 0, "Right ascension (degrees)", NAN);
+        psMetadataAddF64(row, PS_LIST_TAIL, "RA_ERR", 0, "Right ascension error (degrees)", NAN);
+        psMetadataAddF64(row, PS_LIST_TAIL, "DEC", 0, "Declination (degrees)", NAN);
+        psMetadataAddF64(row, PS_LIST_TAIL, "DEC_ERR", 0, "Declination error (degrees)", NAN);
+        psMetadataAddF32(row, PS_LIST_TAIL, "MAG", 0, "Magnitude", NAN);
+        psMetadataAddF32(row, PS_LIST_TAIL, "MAG_ERR", 0, "Magnitude error", NAN);
+        psMetadataAddF32(row, PS_LIST_TAIL, "STARPSF", 0, "EXT_NSIGMA", NAN);
+        psMetadataAddF32(row, PS_LIST_TAIL, "ANGLE", 0, "Position angle of trail (degrees)", NAN);
+        psMetadataAddF32(row, PS_LIST_TAIL, "ANGLE_ERR", 0, "Position angle error (degrees)", NAN);
+        psMetadataAddF32(row, PS_LIST_TAIL, "LENGTH", 0, "Length of trail (arcsec)", NAN);
+        psMetadataAddF32(row, PS_LIST_TAIL, "LENGTH_ERR", 0, "Length error (arcsec)", NAN);
+        psMetadataAddU32(row, PS_LIST_TAIL, "FLAGS", 0, "Detection bit flags", 0);
+        psMetadataAddS64(row, PS_LIST_TAIL, "DIFF_SKYFILE_ID", 0, "Identifier for diff skyfile", 0);
+        if (!psFitsWriteTableEmpty(fits, header, row, OUT_EXTNAME)) {
+            psErrorStackPrint(stderr, "Unable to write empty table.");
+            psFree(header);
+            psFree(row);
+            return false;
+        }
+        psFree(row);
+    } else {
+        psArray *table = psArrayAlloc(det->num); // Table to write
+        for (long i = 0; i < det->num; i++) {
+            psMetadata *row = psMetadataAlloc(); // Output row
+            psMetadataAddF64(row, PS_LIST_TAIL, "RA", 0, "Right ascension (degrees)",
+                             RAD_TO_DEG(det->ra->data.F64[i]));
+            psMetadataAddF64(row, PS_LIST_TAIL, "RA_ERR", 0, "Right ascension error (degrees)",
+                             det->raErr->data.F64[i]);
+            psMetadataAddF64(row, PS_LIST_TAIL, "DEC", 0, "Declination (degrees)",
+                             RAD_TO_DEG(det->dec->data.F64[i]));
+            psMetadataAddF64(row, PS_LIST_TAIL, "DEC_ERR", 0, "Declination error (degrees)",
+                             det->decErr->data.F64[i]);
+            psMetadataAddF32(row, PS_LIST_TAIL, "MAG", 0, "Magnitude", det->mag->data.F32[i]);
+            psMetadataAddF32(row, PS_LIST_TAIL, "MAG_ERR", 0, "Magnitude error", det->magErr->data.F32[i]);
+            psMetadataAddF32(row, PS_LIST_TAIL, "STARPSF", 0, "EXT_NSIGMA", det->extended->data.F32[i]);
+            psMetadataAddF32(row, PS_LIST_TAIL, "ANGLE", 0, "Position angle of trail (degrees)",
+                             det->angle->data.F32[i]);
+            psMetadataAddF32(row, PS_LIST_TAIL, "ANGLE_ERR", 0, "Position angle error (degrees)",
+                             det->angleErr->data.F32[i]);
+            psMetadataAddF32(row, PS_LIST_TAIL, "LENGTH", 0, "Length of trail (arcsec)",
+                             det->length->data.F32[i]);
+            psMetadataAddF32(row, PS_LIST_TAIL, "LENGTH_ERR", 0, "Length error (arcsec)",
+                             det->lengthErr->data.F32[i]);
+            psMetadataAddU32(row, PS_LIST_TAIL, "FLAGS", 0, "Detection bit flags", det->flags->data.U32[i]);
+            psMetadataAddS64(row, PS_LIST_TAIL, "DIFF_SKYFILE_ID", 0, "Identifier for diff skyfile",
+                             det->diffSkyfileId->data.S64[i]);
+            table->data[i] = row;
+        }
+        if (!psFitsWriteTable(fits, header, table, OUT_EXTNAME)) {
+            psErrorStackPrint(stderr, "Unable to write table.");
+            psFree(header);
+            psFree(table);
+            return false;
+        }
+        psFree(table);
+    }
+
+    psFree(header);
+    psFitsClose(fits);
+
+    psTrace("ppMops.write", 1, "Done writing %ld rows to %s", det->num, args->output);
+
+    return true;
+}
Index: trunk/ppTranslate/src/ppTranslateVersion.c
===================================================================
--- trunk/ppTranslate/src/ppTranslateVersion.c	(revision 25923)
+++ trunk/ppTranslate/src/ppTranslateVersion.c	(revision 25923)
@@ -0,0 +1,99 @@
+#ifdef HAVE_CONFIG_H
+#include <config.h>
+#endif
+
+#include <stdio.h>
+#include <pslib.h>
+
+#include "ppTranslateVersionDefinitions.h"
+
+#ifndef PPTRANSLATE_VERSION
+#error "PPTRANSLATE_VERSION is not set"
+#endif
+#ifndef PPTRANSLATE_BRANCH
+#error "PPTRANSLATE_BRANCH is not set"
+#endif
+#ifndef PPTRANSLATE_SOURCE
+#error "PPTRANSLATE_SOURCE is not set"
+#endif
+
+psString ppTranslateVersion(void)
+{
+    char *value = NULL;
+    psStringAppend(&value, "%s@%s", PPTRANSLATE_BRANCH, PPTRANSLATE_VERSION);
+    return value;
+}
+
+psString ppTranslateSource(void)
+{
+    return psStringCopy(PPTRANSLATE_SOURCE);
+}
+
+psString ppTranslateVersionLong(void)
+{
+    psString version = ppTranslateVersion();  // Version, to return
+    psString source = ppTranslateSource();    // Source
+
+    psStringPrepend(&version, "ppTranslate ");
+    psStringAppend(&version, " from %s, built %s, %s", source, __DATE__, __TIME__);
+    psFree(source);
+
+#ifdef __OPTIMIZE__
+    psStringAppend(&version, " optimised");
+#else
+    psStringAppend(&version, " unoptimised");
+#endif
+
+    return version;
+};
+
+
+bool ppTranslateVersionHeader(psMetadata *header)
+{
+    PS_ASSERT_METADATA_NON_NULL(header, false);
+
+    psTime *time = psTimeGetNow(PS_TIME_TAI); // The time now
+    psString timeString = psTimeToISO(time); // The time in an ISO string
+    psFree(time);
+    psString history = NULL;               // History string
+    psStringAppend(&history, "ppTranslate at %s", timeString);
+    psFree(timeString);
+    psMetadataAddStr(header, PS_LIST_TAIL, "HISTORY", PS_META_DUPLICATE_OK, NULL, history);
+    psFree(history);
+
+    psLibVersionHeader(header);
+
+    psString version = ppTranslateVersion(); // Software version
+    psString source  = ppTranslateSource();  // Software source
+
+    psStringPrepend(&version, "ppTranslate version: ");
+    psStringPrepend(&source, "ppTranslate source: ");
+
+    psMetadataAddStr(header, PS_LIST_TAIL, "HISTORY", PS_META_DUPLICATE_OK, NULL, version);
+    psMetadataAddStr(header, PS_LIST_TAIL, "HISTORY", PS_META_DUPLICATE_OK, NULL, source);
+
+    psFree(version);
+    psFree(source);
+
+    return true;
+}
+
+void ppTranslateVersionPrint(void)
+{
+    psTime *time = psTimeGetNow(PS_TIME_TAI); // The time now
+    psString timeString = psTimeToISO(time); // The time in an ISO string
+    psFree(time);
+    psLogMsg("ppTranslate", PS_LOG_INFO, "ppTranslate at %s", timeString);
+    psFree(timeString);
+
+    psString pslib = psLibVersionLong();// psLib version
+    psString ppTranslate = ppTranslateVersionLong(); // ppTranslate version
+
+    psLogMsg("ppTranslate", PS_LOG_INFO, "%s", pslib);
+    psLogMsg("ppTranslate", PS_LOG_INFO, "%s", ppTranslate);
+
+    psFree(pslib);
+    psFree(ppTranslate);
+
+    return;
+}
Index: trunk/ppTranslate/src/ppTranslateVersion.h
===================================================================
--- trunk/ppTranslate/src/ppTranslateVersion.h	(revision 25923)
+++ trunk/ppTranslate/src/ppTranslateVersion.h	(revision 25923)
@@ -0,0 +1,21 @@
+#ifndef PP_TRANSLATE_VERSION
+#define PP_TRANSLATE_VERSION
+
+#include <pslib.h>
+
+/// Return version
+psString ppTranslateVersion(void);
+
+/// Return source
+psString ppTranslateSource(void);
+
+/// Return detailed version information
+psString ppTranslateVersionLong(void);
+
+/// Put version into header
+bool ppTranslateVersionHeader(psMetadata *header);
+
+/// Print version information
+void ppTranslateVersionPrint(void);
+
+#endif
Index: trunk/ppTranslate/src/ppTranslateVersionDefinitions.h.in
===================================================================
--- trunk/ppTranslate/src/ppTranslateVersionDefinitions.h.in	(revision 25923)
+++ trunk/ppTranslate/src/ppTranslateVersionDefinitions.h.in	(revision 25923)
@@ -0,0 +1,8 @@
+#ifndef PPTRANSLATE_VERSION_DEFINITIONS_H
+#define PPTRANSLATE_VERSION_DEFINITIONS_H
+
+#define PPTRANSLATE_VERSION @PPTRANSLATE_VERSION@ // SVN version
+#define PPTRANSLATE_BRANCH  @PPTRANSLATE_BRANCH@  // SVN branch
+#define PPTRANSLATE_SOURCE  @PPTRANSLATE_SOURCE@  // SVN source
+
+#endif
Index: trunk/psconfig/tagsets/ipp-2.8.dist
===================================================================
--- trunk/psconfig/tagsets/ipp-2.8.dist	(revision 25922)
+++ trunk/psconfig/tagsets/ipp-2.8.dist	(revision 25923)
@@ -69,5 +69,5 @@
   YYYYY  DataStore              ipp-2-8          -0
 
-  YYYYY  ppMops                 ipp-2-8          -0
+  YYYYY  ppTranslate            ipp-2-8          -0
 
   YNNNN  extsrc/gpcsw           ipp-2-8          -0
Index: trunk/psconfig/tagsets/ipp-2.9.dist
===================================================================
--- trunk/psconfig/tagsets/ipp-2.9.dist	(revision 25922)
+++ trunk/psconfig/tagsets/ipp-2.9.dist	(revision 25923)
@@ -70,5 +70,5 @@
   YYYYY  DataStore              ipp-2-9          -0
 
-  YYYYY  ppMops                 ipp-2-9          -0
+  YYYYY  ppTranslate            ipp-2-9          -0
 
   YYYYY  extsrc/gpcsw           ipp-2-9          -0
