/* -*- Mode: C; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
/* vim:set sw=2 sts=2 et cin: */
/*
 * This file is part of the MUSE Instrument Pipeline
 * Copyright (C) 2005-2015 European Southern Observatory
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 */
#ifdef HAVE_CONFIG_H
#include <config.h>
#endif

/*----------------------------------------------------------------------------*
 *                             Includes                                       *
 *----------------------------------------------------------------------------*/
#define _BSD_SOURCE /* get setenv() from stdlib.h */
#include <stdlib.h> /* setenv() */

#include <cpl.h>
#include <float.h>
#include <math.h>
#include <string.h>
#include <cpl.h>

#include "muse_geo.h"
#include "muse_instrument.h"

#include "muse_astro.h"
#include "muse_cplwrappers.h"
#include "muse_data_format_z.h"
#include "muse_dfs.h"
#include "muse_pfits.h"
#include "muse_tracing.h"
#include "muse_utils.h"
#include "muse_wavecalib.h"

/*----------------------------------------------------------------------------*/
/**
 * @defgroup muse_geo          Geometrical calibration
 *
 * The following functions deal with the geometrical calibration,
 * i.e. calibrating the relative location of all MUSE slices within
 * the VLT focal plane.
 */
/*----------------------------------------------------------------------------*/
/**@{*/

/*----------------------------------------------------------------------------*/
/**
 * @brief Spots measurement table definition for geometrical calibration.
 *
 * Such a spots table has the following columns:
 * - 'filename': (raw) filename from which this measurement originates
 * - 'image': number of the image in the series
 * - 'POSENC2': x position of the mask in encoder steps
 * - 'POSPOS2': x position of the mask
 * - 'POSENC3': y position of the mask in encoder steps
 * - 'POSPOS3': y position of the mask
 * - 'POSENC4': z position of the mask in encoder steps
 * - 'POSPOS4': z position of the mask
 * - 'VPOS': real vertical position of the mask
 * - 'ScaleFOV': focus scale in VLT focal plane (from the FITS header
 * - 'SubField': sub-field number
 * - 'SliceCCD': slice number as counted on the CCD
 * - 'lambda': wavelength
 * - 'SpotNo': number of this spot within the slice
 * - 'xc': x center of this spot on the CCD
 * - 'yc': y center of this spot on the CCD
 * - 'xfwhm': FWHM in x-direction on the CCD
 * - 'yfwhm': FWHM in y-direction on the CCD
 * - 'flux': flux of the spot as integrated on the CCD image
 * - 'bg': background level around the spot
 * - 'dxcen': distance to center of slice at vertical position yc
 *            (positive: right of center)
 * - 'twidth': trace width of the slice at the vertical CCD position of the spot
 */
/*----------------------------------------------------------------------------*/
const muse_cpltable_def muse_geo_measurements_def[] = {
  { "filename", CPL_TYPE_STRING, "", "%s",
    "(raw) filename from which this measurement originates", CPL_TRUE },
  { "image", CPL_TYPE_INT, "", "%03d", "number of the image in the series", CPL_TRUE },
  { "POSENC2", CPL_TYPE_INT, "", "%d",
    "x position of the mask in encoder steps", CPL_TRUE },
  { "POSPOS2", CPL_TYPE_DOUBLE, "mm", "%.3f", "x position of the mask", CPL_TRUE },
  { "POSENC3", CPL_TYPE_INT, "", "%d",
    "y position of the mask in encoder steps", CPL_TRUE },
  { "POSPOS3", CPL_TYPE_DOUBLE, "mm", "%.3f", "y position of the mask", CPL_TRUE },
  { "POSENC4", CPL_TYPE_INT, "", "%d",
    "z position of the mask in encoder steps", CPL_TRUE },
  { "POSPOS4", CPL_TYPE_DOUBLE, "mm", "%.3f", "z position of the mask", CPL_TRUE },
  { "VPOS", CPL_TYPE_DOUBLE, "mm", "%.3f", "real vertical position of the mask", CPL_TRUE },
  { "ScaleFOV", CPL_TYPE_DOUBLE, "arcsec/mm", "%.3f",
    "focus scale in VLT focal plane (from the FITS header)", CPL_TRUE },
  { "SubField", CPL_TYPE_INT, "", "%02d", "sub-field number", CPL_TRUE },
  { "SliceCCD", CPL_TYPE_INT, "", "%02d",
    "slice number as counted on the CCD", CPL_TRUE },
  { "lambda", CPL_TYPE_DOUBLE, "Angstrom", "%.3f", "wavelength", CPL_TRUE },
  { "SpotNo", CPL_TYPE_INT, "", "%04d",
    "number of this spot within the slice (1 is left, 2 is the central one, 3 is right within the slice)", CPL_TRUE },
  { "xc", CPL_TYPE_DOUBLE, "pix", "%.3f", "x center of this spot on the CCD", CPL_TRUE },
  { "yc", CPL_TYPE_DOUBLE, "pix", "%.3f", "y center of this spot on the CCD", CPL_TRUE },
  { "xfwhm", CPL_TYPE_DOUBLE, "pix", "%.2f", "FWHM in x-direction on the CCD", CPL_TRUE },
  { "yfwhm", CPL_TYPE_DOUBLE, "pix", "%.2f", "FWHM in y-direction on the CCD", CPL_TRUE },
  { "flux", CPL_TYPE_DOUBLE, "", "%.1f",
    "flux of the spot as integrated on the CCD image", CPL_TRUE },
  { "bg", CPL_TYPE_DOUBLE, "", "%f", "background level around the spot", CPL_TRUE },
  { "dxcen", CPL_TYPE_DOUBLE, "pix", "%f",
    "distance to center of slice at vertical position yc (positive: right of center)", CPL_TRUE },
  { "twidth", CPL_TYPE_DOUBLE, "pix", "%f",
    "trace width of the slice at the vertical CCD position of the spot", CPL_TRUE },
  { NULL, 0, NULL, NULL, NULL, CPL_FALSE }
};

/*----------------------------------------------------------------------------*/
/**
 * @brief Geometry table definition.
 *
 * This type of table is used to define the location of all slices of MUSE
 * within the field of view. It is used to create pixel tables.
 *
 * The geometry table has the following columns
 *
 * Mandatory columns:
 * - MUSE_GEOTABLE_FIELD: sub-field (IFU / channel) number
 * - MUSE_GEOTABLE_CCD: the slice number on the CCD, counted from left to right
 * - MUSE_GEOTABLE_SKY: the slice number on the sky
 * - MUSE_GEOTABLE_X: x position within field of view
 * - MUSE_GEOTABLE_Y: y position within field of view
 * - MUSE_GEOTABLE_ANGLE: rotation angle of slice
 * - MUSE_GEOTABLE_WIDTH: width of slice within field of view
 *
 * Extra columns to carry error estimates of the main columns:
 * - MUSE_GEOTABLE_X"err": error estimated of x position within field of view
 * - MUSE_GEOTABLE_Y"err": error estimate of y position within field of view
 * - MUSE_GEOTABLE_ANGLE"err": error estimate of rotation angle
 * - MUSE_GEOTABLE_WIDTH"err": error estimate of slice width
 *
 * Extra columns initially created to support deriving the mandatory coordinates
 * (these disappear during processing and are usually only saved to disk for
 * debugging purposes):
 * - 'stack': slicer stack that this slice belongs to (optical numbering)
 * - 'spot': spot number in this slice
 * - 'xrel': x offset of this spot relative to the slice center
 * - 'xrelerr': error of the relative x offset of this spot
 * - 'xc': x center of this spot on the CCD
 * - 'yc': y center of this spot on the CCD
 * - 'dxl': distance to left edge of slice on the CCD
 * - 'dxr': distance to right edge of slice on the CCD
 * - 'dx': pinhole distance in x on the CCD
 * - 'dxerr': error estimate of the pinhole distance in x on the CCD
 * - 'vpos': (averaged) vertical position of the mask
 * - 'vposerr': error estimated of the (averaged) vertical position of the mask
 * - 'flux': flux of the spot as integrated on the CCD image
 * - 'lambda': wavelength
 */
/*----------------------------------------------------------------------------*/
const muse_cpltable_def muse_geo_table_def[] = {
  { MUSE_GEOTABLE_FIELD, CPL_TYPE_INT, "", "%02d",
    "sub-field (IFU / channel) number", CPL_TRUE },
  { MUSE_GEOTABLE_CCD, CPL_TYPE_INT, "", "%02d",
    "the slice number on the CCD, counted from left to right", CPL_TRUE },
  { MUSE_GEOTABLE_SKY, CPL_TYPE_INT, "", "%02d",
    "the slice number on the sky", CPL_TRUE },
  { MUSE_GEOTABLE_X, CPL_TYPE_DOUBLE, "pix", "%9.4f",
    "x position within field of view", CPL_TRUE },
  { MUSE_GEOTABLE_Y, CPL_TYPE_DOUBLE, "pix", "%9.4f",
    "y position within field of view", CPL_TRUE },
  { MUSE_GEOTABLE_ANGLE, CPL_TYPE_DOUBLE, "deg", "%6.3f",
    "rotation angle of slice", CPL_TRUE },
  { MUSE_GEOTABLE_WIDTH, CPL_TYPE_DOUBLE, "pix", "%.2f",
    "width of slice within field of view", CPL_TRUE },
  { MUSE_GEOTABLE_X"err", CPL_TYPE_DOUBLE, "pix", "%8.4f",
    "error estimated of x position within field of view", CPL_TRUE },
  { MUSE_GEOTABLE_Y"err", CPL_TYPE_DOUBLE, "pix", "%8.4f",
    "error estimate of y position within field of view", CPL_TRUE },
  { MUSE_GEOTABLE_ANGLE"err", CPL_TYPE_DOUBLE, "deg", "%.3f",
    "error estimate of rotation angle", CPL_TRUE },
  { MUSE_GEOTABLE_WIDTH"err", CPL_TYPE_DOUBLE, "pix", "%.2f",
    "error estimate of slice width", CPL_TRUE },
  { "stack", CPL_TYPE_INT, "", "%02d",
    "slicer stack that this slice belongs to (optical numbering)", CPL_TRUE },
  { "spot", CPL_TYPE_INT, "", "%1d", "spot number in this slice", CPL_TRUE },
  { "xrel", CPL_TYPE_DOUBLE, "mm", "%7.4f",
    "x offset of this spot relative to the slice center", CPL_TRUE },
  { "xrelerr", CPL_TYPE_DOUBLE, "mm", "%6.4f",
    "error of the relative x offset of this spot", CPL_TRUE },
  { "xc", CPL_TYPE_DOUBLE, "pix", "%.3f", "x center of this spot on the CCD", CPL_TRUE },
  { "yc", CPL_TYPE_DOUBLE, "pix", "%.3f", "y center of this spot on the CCD", CPL_TRUE },
  { "dxl", CPL_TYPE_DOUBLE, "pix", "%.3f", "distance to left edge of slice on the CCD", CPL_TRUE },
  { "dxr", CPL_TYPE_DOUBLE, "pix", "%.3f", "distance to right edge of slice on the CCD", CPL_TRUE },
  { "dx", CPL_TYPE_DOUBLE, "pix", "%.3f", "pinhole distance in x on the CCD", CPL_TRUE },
  { "dxerr", CPL_TYPE_DOUBLE, "pix", "%.3f",
    "error estimate of the pinhole distance in x on the CCD", CPL_TRUE },
  { "vpos", CPL_TYPE_DOUBLE, "mm", "%.4f",
    "(averaged) vertical position of the mask", CPL_TRUE },
  { "vposerr", CPL_TYPE_DOUBLE, "mm", "%.4f",
    "error estimated of the (averaged) vertical position of the mask", CPL_TRUE },
  { "flux", CPL_TYPE_DOUBLE, "", "%.1f",
    "flux of the spot as integrated on the CCD image", CPL_TRUE },
  { "lambda", CPL_TYPE_DOUBLE, "Angstrom", "%.3f", "wavelength", CPL_TRUE },
  { NULL, 0, NULL, NULL, NULL, CPL_FALSE }
};

/*----------------------------------------------------------------------------*/
/**
  @brief  Extract the part of a geometry table dealing with a given IFU.
  @param  aTable   the input table
  @param  aIFU     the IFU/subfield to extract
  @return a cpl_table * or NULL on error
  @remark The returned object has to be deallocated using cpl_table_delete().

  This function sorts a duplicate of the input table to make sure that all
  information for one subfield is in consecutive order, with the on-CCD slice
  numbers increasing, and then extracts the relevant part of the table.

  @error{set CPL_ERROR_NULL_INPUT\, return NULL, input table is NULL}
  @error{set CPL_ERROR_ILLEGAL_INPUT\, return NULL, invalid IFU number given}
  @error{set CPL_ERROR_ILLEGAL_OUTPUT\, return NULL,
         the data for the given IFU is incomplete (not kMuseSlicesPerCCD slices)}
 */
/*----------------------------------------------------------------------------*/
cpl_table *
muse_geo_table_extract_ifu(const cpl_table *aTable, const unsigned char aIFU)
{
  cpl_ensure(aTable, CPL_ERROR_NULL_INPUT, NULL);
  cpl_ensure(aIFU >= 1 && aIFU <= kMuseNumIFUs, CPL_ERROR_ILLEGAL_INPUT, NULL);

  /* duplicate the input table so that it's not changed */
  cpl_table *intable = cpl_table_duplicate(aTable);

  /* Sort the table by subfield and then slice number on CCD (both        *
   * ascending); the sort order was verified using optical model and INM. */
  cpl_propertylist *sorting = cpl_propertylist_new();
  cpl_propertylist_append_bool(sorting, MUSE_GEOTABLE_FIELD, CPL_FALSE);
  cpl_propertylist_append_bool(sorting, MUSE_GEOTABLE_CCD, CPL_FALSE);
  cpl_table_sort(intable, sorting);
  cpl_propertylist_delete(sorting);

  cpl_table_select_all(intable);
  cpl_table_and_selected_int(intable, MUSE_GEOTABLE_FIELD, CPL_EQUAL_TO, aIFU);
  cpl_table *subtable = cpl_table_extract_selected(intable);
  cpl_table_delete(intable);
#if 0
  printf("table (extracted for IFU %2d)\n", aIFU);
  cpl_table_dump(subtable, 0, 100000, stdout);
  fflush(stdout);
#endif
  int nrow = cpl_table_get_nrow(subtable);
  if (nrow != kMuseSlicesPerCCD) {
    cpl_error_set_message(__func__, CPL_ERROR_ILLEGAL_OUTPUT,
                          "geometry table contains %d instead of %d slices for "
                          "IFU %d", nrow, kMuseSlicesPerCCD, aIFU);
    cpl_table_delete(subtable);
    subtable = NULL;
  } /* if nrow */
  return subtable;
} /* muse_geo_table_extract_ifu() */

/*----------------------------------------------------------------------------*/
/**
  @brief  Compute the area of an IFU in the VLT focal plane.
  @param  aTable   the input table
  @param  aIFU     the IFU/subfield to handle
  @param  aScale   scale factor to use [length / arcmin]
  @return The area or 0. on error.

  This function sums up the widths of the relevant slices in the input geometry
  table (column MUSE_GEOTABLE_WIDTH) for each slicer stack, and multiplies this
  result with the height of a slice in this stack, by multiplying with 1/11 of
  the vertical distance between top and bottom slices (using column
  MUSE_GEOTABLE_Y).

  Since a geometry table is in units of pixels, the result is first computed in
  pix**2.  The factor aScale determines the output unit of the area. Normally
  this should be in cm/arcmin (i.e. ~3.5 cm/arcmin), so that the computed area
  is in cm**2 in the VLT focal plane. The function muse_pfits_get_focu_scale()
  can be used to get relevant information from the FITS headers.

  @error{set CPL_ERROR_NULL_INPUT\, return 0., input table is NULL}
  @error{set CPL_ERROR_ILLEGAL_INPUT\, return 0.,
         invalid IFU number given or incomplete table was input}
 */
/*----------------------------------------------------------------------------*/
double
muse_geo_table_ifu_area(const cpl_table *aTable, const unsigned char aIFU,
                        double aScale)
{
  cpl_ensure(aTable, CPL_ERROR_NULL_INPUT, 0.);

  /* create table containing only the entries for the given IFU */
  cpl_table *table = muse_geo_table_extract_ifu(aTable, aIFU);
  /* we need the standard number of slices otherwise the result is very wrong */
  cpl_size nrow = cpl_table_get_nrow(table);
  cpl_ensure(nrow == kMuseSlicesPerCCD, CPL_ERROR_ILLEGAL_INPUT, 0.);

  /* sort the table by the slice number on the sky, to *
   * be able to directly access the relevant entries   */
  cpl_propertylist *sorting = cpl_propertylist_new();
  cpl_propertylist_append_bool(sorting, MUSE_GEOTABLE_SKY, CPL_FALSE);
  cpl_table_sort(table, sorting);
  cpl_propertylist_delete(sorting);

  /* now go through the slicer stacks one by one, sum up the widths and    *
   * compute the vertical size of the stack, which is 11x the slice height */
  double area = 0., areas[4];
  int istack,
      nperstack = kMuseSlicesPerCCD / 4; /* number of slices per slicer stack */
  for (istack = 0; istack < 4; istack++) {
    cpl_table *stack = cpl_table_extract(table, 12 * istack, nperstack);
    /* get the sizes in pix units from the table, then convert them *
     * to cm, using the scale factors used elsewhere in this module */
    double height = fabs(cpl_table_get(stack, MUSE_GEOTABLE_Y, 0, NULL)
                         - cpl_table_get(stack, MUSE_GEOTABLE_Y, nperstack - 1, NULL))
                  / (nperstack - 1.) /* height [pix] */
                  / kMuseTypicalCubeSizeY * aScale; /* [cm] */
    areas[istack] = cpl_table_get_column_mean(stack, MUSE_GEOTABLE_WIDTH)
                  * height * nperstack /* summed widths [pix] */
                  / kMuseTypicalCubeSizeX * aScale; /* [cm] */
    cpl_table_delete(stack);
#if 0
    cpl_msg_debug(__func__, "areas[%d] = %f", istack, areas[istack]);
#endif
    area += areas[istack];
  } /* for istack (all slicer stacks) */
  cpl_table_delete(table);
  return area;
} /* muse_geo_table_ifu_area() */

/*----------------------------------------------------------------------------*/
/**
  @brief    Select lines suitable for geometrical calibration from a line list.
  @param    aLines   master line list to select from
  @return   a cpl_vector * containing the wavelengths of the selected lines or
            NULL on error

  @error{set CPL_ERROR_NULL_INPUT\, return NULL, aLines is NULL}
  @error{set CPL_ERROR_DATA_NOT_FOUND\, return NULL,
         less than 5 suitable lines were found}
 */
/*----------------------------------------------------------------------------*/
cpl_vector *
muse_geo_lines_get(const cpl_table *aLines)
{
  cpl_ensure(aLines, CPL_ERROR_NULL_INPUT, NULL);

  /* duplicate the table, so that we can do nasty things to it */
  cpl_table *tlines = cpl_table_duplicate(aLines);
  /* cast all floating-point columns to double so that we can use the double *
   * functions without getting errors (in case they are just float)          */
  cpl_table_cast_column(tlines, MUSE_LINE_CATALOG_LAMBDA, MUSE_LINE_CATALOG_LAMBDA,
                        CPL_TYPE_DOUBLE);
  cpl_table_cast_column(tlines, MUSE_LINE_CATALOG_FLUX, MUSE_LINE_CATALOG_FLUX,
                        CPL_TYPE_DOUBLE);
  cpl_table_unselect_all(tlines);

  /* select all lines we really don't want, because they are  *
   * either of the wrong lamp (Xe is not used for geometrical *
   * exposures), have too little flux, are below the MUSE     *
   * wavelength range, or have for other reasons quality.     */
  cpl_table_or_selected_string(tlines, MUSE_LINE_CATALOG_ION, CPL_EQUAL_TO, "XeI");
  cpl_table_or_selected_double(tlines, MUSE_LINE_CATALOG_FLUX, CPL_LESS_THAN, 5000.);
  cpl_table_or_selected_double(tlines, MUSE_LINE_CATALOG_LAMBDA, CPL_LESS_THAN,
                               kMuseNominalLambdaMin);
  cpl_table_or_selected_int(tlines, MUSE_LINE_CATALOG_QUALITY, CPL_LESS_THAN, 1);
  cpl_table_erase_selected(tlines);

  /* We have enough Neon lines, so remove those with low quality or     *
   * low flux, unless they happen to be the last in the remaining table *
   * (since we want to cover as wide a wavelength range as possible).   */
  cpl_table_or_selected_string(tlines, MUSE_LINE_CATALOG_ION, CPL_EQUAL_TO, "NeI");
  cpl_table_and_selected_int(tlines, MUSE_LINE_CATALOG_QUALITY, CPL_LESS_THAN, 2);
  cpl_table_unselect_row(tlines, cpl_table_get_nrow(tlines) - 1);
  cpl_table_erase_selected(tlines);
  cpl_table_or_selected_string(tlines, MUSE_LINE_CATALOG_ION, CPL_EQUAL_TO, "NeI");
  cpl_table_and_selected_double(tlines, MUSE_LINE_CATALOG_FLUX, CPL_LESS_THAN, 10000.);
  cpl_table_unselect_row(tlines, cpl_table_get_nrow(tlines) - 1);
  cpl_table_erase_selected(tlines);

  /* now the selection should be done, just check the number *
   * before converting the "lambda" column into a vector     */
  int nlines = cpl_table_get_nrow(tlines);
  if (nlines <= 5) {
    cpl_table_delete(tlines);
    cpl_error_set_message(__func__, CPL_ERROR_DATA_NOT_FOUND,
                          "Only found %d suitable arc lines!", nlines);
    return NULL;
  }
  cpl_vector *lines = cpl_vector_wrap(nlines,
                                      cpl_table_unwrap(tlines, MUSE_LINE_CATALOG_LAMBDA));
  cpl_table_delete(tlines);
  cpl_msg_info(__func__, "Using a list of %d arc lines (from %.1f to %.1f "
               "Angstrom)", nlines, cpl_vector_get(lines, 0),
               cpl_vector_get(lines, nlines - 1));
  return lines;
} /* muse_geo_lines_get() */

/*----------------------------------------------------------------------------*/
/**
  @brief  Detect spots on a combined image and measure them on the corresponding
          series of images.
  @param  aImage      the combined image of all pinhole mask exposures
  @param  aList       the series of all related pinhole mask exposures
  @param  aTrace      the table containing the trace solution
  @param  aWave       the table containing the wavelength calibration solution
  @param  aLines      vector with wavelengths of arc lines to use
  @param  aSigma      spot detection sigma level (in terms of median deviation
                      above the median)
  @param  aCentroid   type of centroiding to use for the spot measurements
  @return a cpl_table * or NULL on error
  @remark The returned object has to be deallocated using cpl_table_delete().

  Loop over all arc lines in aLines and over all slices on the CCD:
  first determine the approximate position of the wavelength in the slice and
  use this image location to construct a box within which the spots (arc lines
  of the pinholes) are detected, on aImage. If the number of detections is not
  kMuseCUmpmSpotsPerSlice, skip the rest of the processing for this slice.

  Using the now known locations of these spots, loop through aList and measure
  each spot at the detected position in each image. The local background is
  computed in a box of halfsize BACKGROUND_HALFSIZE (XXX 7 pix), the flux of
  the spot is intergrated within a halfsize of MEASUREMENT_HALFSIZE (XXX 5
  pix). If aCentroid is MUSE_GEO_CENTROID_GAUSSIAN, the resulting measurement
  is refined using a Gaussian fit, to give final CCD position and FWHM. Since
  the input data should contain only emission features (arc lines) negative
  integrated fluxes are set to zero. Together with the x-/y-centroid and the
  x-/y-FWHM the measurements and the mask position are saved to the output
  table.

  The columns of the table are given by muse_geo_measurements_def. Note that
  the x- and y-FWHM may be negative, in case they could not be determined.

  @error{set CPL_ERROR_NULL_INPUT\, return NULL,
         one of aImage aList\, aTrace\, aWave\, or aLines is NULL}
  @error{set CPL_ERROR_ILLEGAL_INPUT\, return NULL, aSigma is not positive}
  @error{set CPL_ERROR_ILLEGAL_INPUT\, return NULL,
         aList contains less than five images}
  @error{set CPL_ERROR_ILLEGAL_INPUT\, return NULL,
         aLines contains less than three wavelengths}
  @error{set CPL_ERROR_ILLEGAL_INPUT\, return NULL,
         aCentroid does not contain a supported value}
  @error{continue with next slice\, propagate error,
         tracing and/or wavelength calibration polynomials could not be constructed for a given slice}
 */
/*----------------------------------------------------------------------------*/
cpl_table *
muse_geo_measure_spots(muse_image *aImage, muse_imagelist *aList,
                       const cpl_table *aTrace, const cpl_table *aWave,
                       const cpl_vector *aLines, double aSigma,
                       muse_geo_centroid_type aCentroid)
{
  cpl_ensure(aImage && aList && aTrace && aWave && aLines, CPL_ERROR_NULL_INPUT,
             NULL);
  cpl_ensure(aSigma > 0., CPL_ERROR_ILLEGAL_INPUT, NULL);
  unsigned int nimages = muse_imagelist_get_size(aList);
  cpl_ensure(nimages >= 5, CPL_ERROR_ILLEGAL_INPUT, NULL);
  int nlines = cpl_vector_get_size(aLines);
  cpl_ensure(nlines >= 3, CPL_ERROR_ILLEGAL_INPUT, NULL);
  cpl_ensure(aCentroid <= MUSE_GEO_CENTROID_GAUSSIAN, CPL_ERROR_ILLEGAL_INPUT,
             NULL);

  int ny = cpl_image_get_size_y(aImage->data),
      nentries = kMuseSlicesPerCCD * kMuseCUmpmSpotsPerSlice * nlines * nimages;
  cpl_table *measurements = muse_cpltable_new(muse_geo_measurements_def,
                                              nentries);
  const unsigned char ifu = muse_utils_get_ifu(aImage->header);
  int iline, irow = 0;
  for (iline = 0; iline < nlines; iline++) {
    double lambda = cpl_vector_get(aLines, iline);
    cpl_msg_info(__func__, "Searching for line %d (%.3f Angstrom) in IFU %2hhu",
                 iline + 1, lambda, ifu);

    unsigned short nslice;
    for (nslice = 1; nslice <= kMuseSlicesPerCCD; nslice++) {
      cpl_polynomial **ptrace = muse_trace_table_get_polys_for_slice(aTrace,
                                                                     nslice),
                     *pwave = muse_wave_table_get_poly_for_slice(aWave, nslice);
      if (!ptrace || !pwave) {
        muse_trace_polys_delete(ptrace);
        cpl_polynomial_delete(pwave);
        continue;
      }
      double xc = cpl_polynomial_eval_1d(ptrace[MUSE_TRACE_CENTER], ny / 2, NULL);
      /* extract a 1D polynomial at the approximate slice center, *
       * since evaluating that is faster than the one in 2D       */
      cpl_polynomial *pxconst = cpl_polynomial_new(1);
      cpl_size p = 0;
      cpl_polynomial_set_coeff(pxconst, &p, xc);
      cpl_polynomial *pywave = cpl_polynomial_extract(pwave, 0, pxconst);
      cpl_polynomial_delete(pxconst);
      /* search for y-position where the wavelength approximately matches */
      double yc = 1, lbda = -1;
      while (fabs(lambda - lbda) > 1.) {
        lbda = cpl_polynomial_eval_1d(pywave, yc, NULL);
        yc += 0.5; /* better do small steps */
        if (yc > kMuseOutputYTop) { /* safeguard against infinite loop */
          break;
        }
      } /* while */
      cpl_polynomial_delete(pywave);
#if 0
      cpl_msg_debug(__func__, "--> %.3f --> %f,%f in slice %2hu",
                    lbda, xc, yc, nslice);
#endif
      cpl_polynomial_delete(pwave);
      /* if the difference is still large, then the polynomial *
       * was faulty, so warn and continue with the next slice  */
      if (fabs(lambda - lbda) > 1.) {
        cpl_msg_warning(__func__, "Polynomial in slice %2hu of IFU %2hhu appears"
                        " to be faulty! Skipping measurement of line %d (%.1f "
                        "Angstrom)", nslice, ifu, iline + 1, lambda);
        muse_trace_polys_delete(ptrace);
        continue;
      }

      /* we are ~0.5 pix to high, but that should be fine on average, *
       * because we now extract a box that should be large enough     */
#define DETECTION_HALFSIZE 7
      int xl = lround(cpl_polynomial_eval_1d(ptrace[MUSE_TRACE_LEFT], yc, NULL)),
          xr = lround(cpl_polynomial_eval_1d(ptrace[MUSE_TRACE_RIGHT], yc, NULL)),
          yb = lround(yc - DETECTION_HALFSIZE),
          yt = lround(yc + DETECTION_HALFSIZE);
      cpl_image *box = cpl_image_extract(aImage->data, xl, yb, xr, yt);
      /* smooth the image with a Gauss-filter before creating detection mask */
      cpl_image *fbox = cpl_image_duplicate(box);
      cpl_matrix *gkernel = muse_matrix_new_gaussian_2d(2, 2, 1.);
      cpl_image_filter(fbox, box, gkernel, CPL_FILTER_LINEAR, CPL_BORDER_FILTER);
      cpl_matrix_delete(gkernel);
      cpl_stats_mode mode = CPL_STATS_MEDIAN | CPL_STATS_MEDIAN_DEV;
      cpl_stats *s = cpl_stats_new_from_image(box, mode);
      double limit = cpl_stats_get_median(s)
                   + aSigma * cpl_stats_get_median_dev(s);
      cpl_mask *mask = cpl_mask_threshold_image_create(fbox, limit, DBL_MAX);
#if 0 /* file debug output for spot detection */
      char *fn1 = cpl_sprintf("box_%02hu.fits", nslice),
           *fn2 = cpl_sprintf("boxf_%02hu.fits", nslice),
           *fn3 = cpl_sprintf("boxf_%02hu_mask.fits", nslice);
      cpl_image_save(box, fn1, CPL_TYPE_UNSPECIFIED, NULL, CPL_IO_CREATE);
      cpl_image_save(fbox, fn2, CPL_TYPE_UNSPECIFIED, NULL, CPL_IO_CREATE);
      cpl_mask_save(mask, fn3, NULL, CPL_IO_CREATE);
      cpl_free(fn1);
      cpl_free(fn2);
      cpl_free(fn3);
#endif
      /* extract the apertures on the original unsmoothed image */
      cpl_apertures *apertures = cpl_apertures_extract_mask(box, mask);
#if 0 /* debugging of box, statistics, and apertures */
      cpl_msg_debug(__func__, "stats in box [%d:%d,%d:%d] --> limit = "
                    "%f + %.1f * %f = %f, apertures:", xl, xr, yb, yt,
                    cpl_stats_get_median(s), aSigma,
                    cpl_stats_get_median_dev(s), limit);
      cpl_apertures_dump(apertures, stdout);
      fflush(stdout);
#endif
      cpl_mask_delete(mask);
      cpl_stats_delete(s);
      cpl_image_delete(fbox);
      cpl_image_delete(box);
      cpl_errorstate es = cpl_errorstate_get();
      int nspots = cpl_apertures_get_size(apertures);
      if (nspots < 0) {
        nspots = 0;
      }
      if (!apertures || nspots != kMuseCUmpmSpotsPerSlice) {
        cpl_msg_debug(__func__, "found %d spot%s (need %hhu) down to the %.1f"
                      "-sigma limit in slice %2d for wavelength %.3f Angstrom "
                      "in box [%d:%d,%d:%d]", nspots, nspots == 1 ? "" : "s",
                      kMuseCUmpmSpotsPerSlice, aSigma, nslice, lambda,
                      xl, xr, yb, yt);
        muse_trace_polys_delete(ptrace);
        cpl_apertures_delete(apertures);
        cpl_errorstate_set(es);
        continue;
      }
#if 0
      cpl_msg_debug(__func__, "found %d spots using the %.1f-sigma limit in "
                    "slice %2d for wavelength %.3f Angstrom in box [%d:%d,%d:%d]",
                    nspots, aSigma, nslice, lambda, xl, xr, yb, yt);
      cpl_apertures_dump(apertures, stdout);
      fflush(stdout);
#endif
      /* Make sure that the spots are ordered in increasing x-pixel position. *
       * Since the apertures cannot be sorted by that, use a temporary matrix *
       * to sort and an index vector to store the sorted aperture numbers.    */
      cpl_matrix *mspots = cpl_matrix_new(nspots, 2);
      int naper;
      for (naper = 1; naper <= nspots; naper++) {
        double xpos = cpl_apertures_get_centroid_x(apertures, naper);
        cpl_matrix_set(mspots, naper - 1, 0, xpos);
        cpl_matrix_set(mspots, naper - 1, 1, naper);
      } /* for naper (all spot apertures) */
      cpl_matrix_sort_rows(mspots, 1); /* this sorts by decreasing x-position! */
#if 0
      printf("mspots sorted:\n");
      cpl_matrix_dump(mspots, stdout);
      fflush(stdout);
#endif
      /* now create index buffer, with reverse order */
      int idx, *aperidx = cpl_calloc(nspots, sizeof(int));
      for (naper = 1, idx = nspots - 1; naper <= nspots && idx >= 0; naper++, idx--) {
        /* save aperture number at correct index position */
        aperidx[naper - 1] = cpl_matrix_get(mspots, idx, 1);
      } /* for naper / i */
      cpl_matrix_delete(mspots);

      /* now measure the three spots in each of the images of the list *
       * and record the spot properties as well as those of the image  */
      unsigned int k;
      for (k = 0; k < nimages; k++) {
        muse_image *image = muse_imagelist_get(aList, k);
        int posenc[4];
        double pospos[4],
               posang = muse_astro_posangle(image->header);
        unsigned short i;
        for (i = 1; i <= 4; i++) {
          posenc[i-1] = muse_pfits_get_posenc(image->header, i);
          pospos[i-1] = muse_pfits_get_pospos(image->header, i);
        }

#define MEASUREMENT_HALFSIZE 5
#define BACKGROUND_HALFSIZE 7
        for (idx = 0; idx < nspots; idx++) {
          naper = aperidx[idx];
          double xpos = cpl_apertures_get_centroid_x(apertures, naper) + xl,
                 ypos = cpl_apertures_get_centroid_y(apertures, naper) + yb;
#if 0
          printf("aper %d idx %d xpos %f\n", naper, idx, xpos);
          fflush(stdout);
#endif
          cpl_stats_mode mspot = CPL_STATS_FLUX | CPL_STATS_CENTROID,
                         mbg = CPL_STATS_MEAN;
          cpl_stats *sspot = cpl_stats_new_from_image_window(image->data, mspot,
                                                             xpos - MEASUREMENT_HALFSIZE,
                                                             ypos - MEASUREMENT_HALFSIZE,
                                                             xpos + MEASUREMENT_HALFSIZE,
                                                             ypos + MEASUREMENT_HALFSIZE),
                    *sbg = cpl_stats_new_from_image_window(image->data, mbg,
                                                           xpos - BACKGROUND_HALFSIZE,
                                                           ypos - BACKGROUND_HALFSIZE,
                                                           xpos + BACKGROUND_HALFSIZE,
                                                           ypos + BACKGROUND_HALFSIZE);
          int npix = cpl_stats_get_npix(sspot);
          double bg = cpl_stats_get_mean(sbg),
                 flux = cpl_stats_get_flux(sspot) - bg * npix;
          if (flux < 0.) {
            flux = 0.;
          }
          cpl_stats_delete(sbg);
          double xcentroid = cpl_stats_get_centroid_x(sspot),
                 ycentroid = cpl_stats_get_centroid_y(sspot);
          cpl_stats_delete(sspot);
          double xfwhm, yfwhm;
          if (aCentroid == MUSE_GEO_CENTROID_GAUSSIAN) {
            /* try a Gaussian fit to derive a better centroid position */
            cpl_array *gpars = cpl_array_new(7, CPL_TYPE_DOUBLE);
            cpl_array_set(gpars, 0, bg);
            cpl_array_set(gpars, 1, flux);
            cpl_array_set(gpars, 3, xcentroid);
            cpl_array_set(gpars, 4, ycentroid);
            cpl_array_set(gpars, 5, 2.); /* assume just critical sampling */
            cpl_array_set(gpars, 6, 2.);
            cpl_fit_image_gaussian(image->data, NULL, lround(xcentroid), lround(ycentroid),
                                   2 * MEASUREMENT_HALFSIZE + 1, 2 * MEASUREMENT_HALFSIZE + 1,
                                   gpars, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
            xcentroid = cpl_array_get(gpars, 3, NULL);
            ycentroid = cpl_array_get(gpars, 4, NULL);
            xfwhm = cpl_array_get(gpars, 5, NULL) * CPL_MATH_FWHM_SIG;
            yfwhm = cpl_array_get(gpars, 6, NULL) * CPL_MATH_FWHM_SIG;
            cpl_array_delete(gpars);
          } else {
            cpl_image_get_fwhm(image->data, lround(xcentroid), lround(ycentroid),
                               &xfwhm, &yfwhm);
          }
          if (cpl_propertylist_has(image->header, MUSE_HDR_TMP_FN)) {
            cpl_table_set_string(measurements, "filename", irow,
                                 cpl_propertylist_get_string(image->header,
                                                             MUSE_HDR_TMP_FN));
          } else {
            cpl_table_set_string(measurements, "filename", irow, "unknown");
          }
          cpl_table_set_int(measurements, "image", irow, k + 1);
          cpl_table_set_int(measurements, "POSENC2", irow, posenc[1]);
          cpl_table_set(measurements, "POSPOS2", irow, pospos[1]);
          cpl_table_set_int(measurements, "POSENC3", irow, posenc[2]);
          cpl_table_set(measurements, "POSPOS3", irow, pospos[2]);
          cpl_table_set_int(measurements, "POSENC4", irow, posenc[3]);
          cpl_table_set(measurements, "POSPOS4", irow, pospos[3]);
          /* compute the "real" vertical position of the mask, *
           * as VPOS = POS3.POS / sin(POSANG)                  */
          cpl_table_set(measurements, "VPOS", irow,
                        pospos[2] / sin(posang * CPL_MATH_RAD_DEG)); // XXX this is total rubbish, it will crash if POSANG==0!
          cpl_table_set_double(measurements, "ScaleFOV", irow,
                               muse_pfits_get_focu_scale(image->header));
          cpl_table_set_int(measurements, "SubField", irow, ifu);
          cpl_table_set_int(measurements, "SliceCCD", irow, nslice);
          cpl_table_set(measurements, "lambda", irow, lambda);
          cpl_table_set_int(measurements, "SpotNo", irow, idx + 1);
          cpl_table_set(measurements, "xc", irow, xcentroid);
          cpl_table_set(measurements, "yc", irow, ycentroid);
          cpl_table_set(measurements, "xfwhm", irow, xfwhm);
          cpl_table_set(measurements, "yfwhm", irow, yfwhm);
          cpl_table_set(measurements, "flux", irow, flux);
          cpl_table_set(measurements, "bg", irow, bg);
          /* also compute the relative position towards the slice center [pix] */
          double xcslice = cpl_polynomial_eval_1d(ptrace[MUSE_TRACE_CENTER], ycentroid, NULL);
          cpl_table_set(measurements, "dxcen", irow, xcentroid - xcslice);
          /* and the slice width at this CCD position as traced [pix] */
          double twidth = cpl_polynomial_eval_1d(ptrace[MUSE_TRACE_RIGHT], ycentroid, NULL)
                        - cpl_polynomial_eval_1d(ptrace[MUSE_TRACE_LEFT], ycentroid, NULL);
          cpl_table_set(measurements, "twidth", irow, twidth);
#if 0     /* exclusion of cosmic rays or other rubbish by detection of weird   *
           * FWHM values did not work at all but excluded lots of valid points */
          if (xfwhm < 0 || yfwhm < 0) {
            cpl_msg_warning(__func__, "xfwhm and/or yfwhm are invalid: %f, %f:", xfwhm, yfwhm);
            if (xfwhm < 0) {
              cpl_table_set_invalid(measurements, "xfwhm", irow);
            }
            if (yfwhm < 0) {
              cpl_table_set_invalid(measurements, "yfwhm", irow);
            }
            cpl_table_dump(measurements, irow, 1, stdout);
            fflush(stdout);
          }
#endif
          irow++;
        } /* for naper (all spot apertures) */
      } /* for k (all images in list) */
      cpl_apertures_delete(apertures);
      cpl_free(aperidx);
      muse_trace_polys_delete(ptrace);
    } /* for nslice */
  } /* for iline */

  /* clean up unused rows */
  cpl_table_erase_invalid(measurements);

  /* sort table to get spots close together */
  cpl_propertylist *order = cpl_propertylist_new();
  cpl_propertylist_append_bool(order, "lambda", CPL_FALSE);
  cpl_propertylist_append_bool(order, "SliceCCD", CPL_FALSE);
  cpl_propertylist_append_bool(order, "SpotNo", CPL_FALSE);
  cpl_propertylist_append_bool(order, "VPOS", CPL_FALSE);
  cpl_table_sort(measurements, order);
  cpl_propertylist_delete(order);

  return measurements;
} /* muse_geo_measure_spots() */

/*----------------------------------------------------------------------------*/
/**
  @brief  Use spot measurements of one IFU to compute vertical pinhole distance.
  @param  aSpots      table containing measurements of all spots
  @param  aIFU        IFU number, only for (debug) output
  @param  aNSlice     slice number on the CCD, for spot selection
  @param  aNSpot      spot number in this slice, for spot selection
  @param  aLambda     wavelength of the spot to use, for spot selection
  @param  aVPosRef    vertical reference position, for peak selection
                      (should be negative if there is no reference yet)
  @param  aVerifyDY   if to run vertical pinhole distance with full debug
                      output into many files
  @param  aDY         array to record the vertical pinhole distance measurement
  @return a cpl_table * with all entries belonging to the best peak or NULL on
          error

  Extract all table entries belonging to the given aNSlice, aNSpot, aLambda
  combination into a new table. Convert the "flux" column of this table into an
  image, and use this for peak detection. If aVPosRef is positive, try to
  select the peak closest to this given vertical position. (The vertical mask
  position of a given peak is accesible using the "VPOS" column.) Extract the
  table values around the chosen peak into the returned table.

  If the spot-data of the selected combination contains two or more peaks, then
  the distance of their centroids is measured (in mm) and added to the aDY
  array.

  @error{quietly return NULL, aSpots is NULL}
  @error{skip pinhole distance computation, aDY is NULL}
  @error{output debug message\, return NULL,
         combination of slice/wavelength/spot not found in aSpots}
  @error{output warning message\, return NULL,
         detection of peaks fails for the current combination of slice/wavelength/spot}
  @error{output warning message\, return NULL,
         all peaks found are too close to one end of the exposure series}
 */
/*----------------------------------------------------------------------------*/
static cpl_table *
muse_geo_get_spot_peaks(cpl_table *aSpots, unsigned char aIFU,
                        unsigned short aNSlice, unsigned char aNSpot,
                        double aLambda, double aVPosRef, cpl_boolean aVerifyDY,
                        cpl_array *aDY)
{
  if (!aSpots) { /* return without raising an error */
    return NULL;
  }

  /* This is not very efficient (one could use the sortedness of the *
   * table for a more clever selection algorithm) but no bottleneck, *
   * so leave it like this for the moment.                           */
  cpl_table_unselect_all(aSpots);
  cpl_size irow, nrow = cpl_table_get_nrow(aSpots);
  for (irow = 0; irow < nrow; irow++) {
    if (cpl_table_get_int(aSpots, "SliceCCD", irow, NULL) == aNSlice &&
        cpl_table_get_int(aSpots, "SpotNo", irow, NULL) == aNSpot &&
        cpl_table_get_double(aSpots, "lambda", irow, NULL) == aLambda) {
      cpl_table_select_row(aSpots, irow);
    }
  } /* for irow */
  cpl_size nextracted = cpl_table_count_selected(aSpots);
  if (nextracted < 1) { /* no entries for this combination */
    /* this means that we cannot do the analysis for this slice at *
     * this wavelength at all, skip the rest of the spots, too     */
    cpl_msg_debug(__func__, "No detection for spot %1hhu in slice %2hu of IFU "
                  "%hhu at wavelength %.3f", aNSpot, aNSlice, aIFU, aLambda);
    return NULL;
  }
  cpl_table *tspot = cpl_table_extract_selected(aSpots);
#if 0
  printf("tspot:\n");
  cpl_table_dump(tspot, 0, 10000, stdout);
  fflush(stdout);
#endif
  /* convert the "flux" table column into an image and detect peaks */
  int nsrow = cpl_table_get_nrow(tspot);
  cpl_image *imflux = cpl_image_wrap(nsrow, 1,
                                     CPL_TYPE_DOUBLE,
                                     cpl_table_get_data_double(tspot, "flux"));
  /* create a mask of the fiducial peaks and filter (dilate) *
   * the mask to make sure to include enough of the flux     */
  cpl_stats *s = cpl_stats_new_from_image(imflux,
                                          CPL_STATS_MEDIAN | CPL_STATS_MEDIAN_DEV);
  double limit = cpl_stats_get_median(s) + cpl_stats_get_median_dev(s) * 0.5;
  cpl_stats_delete(s);
  if (limit > 500.) {
    limit = 500.;
  }
  cpl_mask *mask = cpl_mask_threshold_image_create(imflux, limit, DBL_MAX);
  cpl_mask *kernel = cpl_mask_new(3, 1);
  cpl_mask_not(kernel);
  cpl_mask *mask2 = cpl_mask_duplicate(mask);
  cpl_mask_filter(mask, mask2, kernel, CPL_FILTER_DILATION, CPL_BORDER_NOP);
  cpl_mask_delete(mask2);
  cpl_mask_delete(kernel);
  cpl_apertures *aper = cpl_apertures_extract_mask(imflux, mask);
  cpl_mask_delete(mask);
  if (!aper) {
    cpl_msg_info(__func__, "No detection for spot %1hhu in slice %2hu of IFU "
                 "%2hhu at wavelength %.3f (hope for other wavelengths!)",
                 aNSpot, aNSlice, aIFU, aLambda);
    cpl_table_delete(tspot);
    cpl_image_unwrap(imflux);
    return NULL; /* no analysis possible, skip spots in this slice at this wavelength */
  }
  /* take aperture closest to the image center, but only if it's not on the border */
  double dcenter = DBL_MAX;
  int naper, ndcenter = -1;
  for (naper = 1; naper <= cpl_apertures_get_size(aper); naper++) {
    /* exclude apertures with too few positions */
    int npos = cpl_apertures_get_npix(aper, naper);
    if (cpl_apertures_get_size(aper) > 1 && npos < 3) {
      cpl_msg_debug(__func__, "IFU %2hhu SliceCCD %2d spot %1hhu lambda %.3f, "
                    "aperture %d: only %d positions -> skip", aIFU, aNSlice,
                    aNSpot, aLambda, naper, npos);
      continue;
    }
    /* start x-reference at the VPOS values of the middle table row */
    double xref = aVPosRef > 0 ? aVPosRef
                : cpl_table_get_double(tspot, "VPOS", (nsrow + 1) / 2, NULL),
           xcentroid = cpl_apertures_get_centroid_x(aper, naper);
    /* interpolate the corresponding vpos value of this xcentroid, *
     * take into account that there might be missing entries!      */
    irow = 0;
    while (++irow + 1 < xcentroid) ;
    double pp1 = cpl_table_get_double(tspot, "VPOS", irow - 1, NULL),
           pp2 = cpl_table_get_double(tspot, "VPOS", irow, NULL),
           ppfrac = xcentroid - irow;
#if 0
    cpl_msg_debug(__func__, "%"CPL_SIZE_FORMAT" (%f) --> %f %f ==> %f", irow,
                  xcentroid, pp1, pp2, pp1 * (1 - ppfrac) + pp2 * ppfrac);
#endif
    double ppcentroid = pp1 * (1 - ppfrac) + pp2 * ppfrac;
    /* now computed distance to the center of the previous spot */
    double dc = fabs(ppcentroid - xref);
    int x1 = cpl_apertures_get_left(aper, naper),
        x2 = cpl_apertures_get_right(aper, naper);
    if (dc < dcenter && x1 > 1 && x2 < nsrow) {
      dcenter = dc;
      ndcenter = naper;
    } /* if */
  } /* for naper */

  /* derive the vertical pinhole distance (which only nominally is 0.6135 mm) */
  if (aDY || aVerifyDY) {
    for (naper = 1; naper < cpl_apertures_get_size(aper); naper++) {
      int l1 = cpl_apertures_get_left(aper, naper),
          r1 = cpl_apertures_get_right(aper, naper),
          l2 = cpl_apertures_get_left(aper, naper + 1),
          r2 = cpl_apertures_get_right(aper, naper + 1);
      if (l1 > 1 && r1 < nsrow && l2 > 1 && r2 < nsrow) {
        /* compute peak centroids for both selected apertures */
        double peak[2];
        int n2aper;
        for (n2aper = naper; n2aper <= naper + 1; n2aper++) {
          cpl_size irow1 = cpl_apertures_get_left(aper, n2aper) - 1,
                   irow2 = cpl_apertures_get_right(aper, n2aper) - 1;
          double vpos = 0., ftot = 0.;
          for (irow = irow1; irow <= irow2; irow++) {
            double flux = cpl_table_get(tspot, "flux", irow, NULL);
            ftot += flux;
            vpos += cpl_table_get(tspot, "VPOS", irow, NULL) * flux;
          } /* for irow */
          peak[n2aper - naper] = vpos / ftot;
        } /* for n2aper */
        double xcdiff = fabs(peak[1] - peak[0]);
        if (aDY) { /* record the peak distance in the in/output array */
          /* set up index for writing into the aDY array */
          cpl_errorstate state = cpl_errorstate_get();
          cpl_size idy = 0, ndy = cpl_array_get_size(aDY);
          while (cpl_array_is_valid(aDY, idy) > 0) { /* skip all valid entries */
            idy++;
          }
          if (cpl_array_get_size(aDY) <= idy) {
            cpl_array_set_size(aDY, ndy * 1.5);
            cpl_errorstate_set(state);
          }
#if 0
          cpl_msg_debug(__func__, "xcdiff = %f (%f - %f = %f) / index %"CPL_SIZE_FORMAT,
                        xcdiff, peak[1], peak[0], peak[1] - peak[0], idy);
#endif
          cpl_array_set_double(aDY, idy, xcdiff);
        } /* if aDY */
        if (aVerifyDY) {
          printf("\"centroids_d_%f.dat\" u 18:16 t \"d %f (%f %f)\" w lp, \\\n",
                 xcdiff, xcdiff, peak[0], peak[1]);
          char *fn = cpl_sprintf("centroids_d_%f.dat", xcdiff);
          FILE *fp = fopen(fn, "w");
          fprintf(fp, "# good centroids at %f and %f --> d = %f mm\n#", peak[0], peak[1], xcdiff);
          cpl_table_dump(tspot, 0, 10000, fp);
          fflush(fp);
          fclose(fp);
          cpl_free(fn);
        } /* if aVerifyDY */
      } /* if non-border apertures */
    } /* for naper (all but last apertures) */
  } /* if dy verification required */
  if (ndcenter < 1) { /* didn't find a suitable aperture */
    cpl_msg_info(__func__, "Motion of spot %1hhu in slice %2hu of IFU "
                 "%2hhu at wavelength %.3f did not result in usable "
                 "coverage (hope for other wavelengths!)", aNSpot, aNSlice,
                 aIFU, aLambda);
    cpl_table_delete(tspot);
    cpl_apertures_delete(aper);
    cpl_image_unwrap(imflux);
    return NULL; /* no analysis possible, skip spots in this slice at this wavelength */
  }
  cpl_size irow1 = cpl_apertures_get_left(aper, ndcenter) - 1,
           irow2 = cpl_apertures_get_right(aper, ndcenter) - 1;
  cpl_apertures_delete(aper);
  cpl_image_unwrap(imflux);

  /* select the relevant table rows*/
  cpl_table_unselect_all(tspot);
  for (irow = irow1; irow <= irow2; irow++) {
    cpl_table_select_row(tspot, irow);
  } /* for irow */
  cpl_table *result = cpl_table_extract_selected(tspot);
  cpl_table_delete(tspot);
  return result;
} /* muse_geo_get_spot_peaks() */

/*----------------------------------------------------------------------------*/
/**
  @brief  Use spot measurements of one IFU to compute vertical pinhole distance.
  @param  aDY     the array to which to add the distance measurements
  @param  aSpots  table containing measurements of all spots
  @return CPL_ERROR_NONE on success, another CPL error on failure

  Loop over over all slices on the CCD, over all lines, and over all
  (kMuseCUmpmSpotsPerSlice) spots:
  if the spot-data of a slice contains two or more peaks, then the distance of
  their centroids is measured using the VPOS of the spot measurements and added
  to the aDY array.

  The modification of the input array is contained in a single critical section,
  so that this function can be used in parallel on the same aDY array.

  @error{return CPL_ERROR_NULL_INPUT, aDY or aSpots are NULL}
  @error{return CPL_ERROR_INCOMPATIBLE_INPUT, aDY is not of type CPL_TYPE_DOUBLE}
  @error{return CPL_ERROR_ILLEGAL_INPUT,
         aSpots contains less than 10 rows}
  @error{return CPL_ERROR_INCOMPATIBLE_INPUT,
         aSpots does not have all the columns as defined by muse_geo_measurements_def}
  @error{return CPL_ERROR_ILLEGAL_INPUT,
         multiple or invalid sub-field number(s) found in input table}
  @error{return CPL_ERROR_ILLEGAL_INPUT,
         aSpots contains a range of values of ScaleFOV}
  @error{output debug message\, skip the current slice\, continue with the next wavelength,
         combination of slice/wavelength/spot not found in aSpots}
  @error{output warning message\, skip the current slice\, continue with the next wavelength,
         detection of peaks fails for the current combination of slice/wavelength/spot}
  @error{output warning message\, skip the current slice\, continue with the next wavelength,
         all peaks found are too close to one end of the exposure series}
 */
/*----------------------------------------------------------------------------*/
cpl_error_code
muse_geo_compute_pinhole_local_distance(cpl_array *aDY, cpl_table *aSpots)
{
  cpl_ensure_code(aDY && aSpots, CPL_ERROR_NULL_INPUT);
  cpl_ensure_code(cpl_array_get_type(aDY) == CPL_TYPE_DOUBLE,
                  CPL_ERROR_INCOMPATIBLE_INPUT);
  cpl_size nrow = cpl_table_get_nrow(aSpots);
  cpl_ensure_code(nrow > 10, CPL_ERROR_ILLEGAL_INPUT);
  cpl_ensure_code(muse_cpltable_check(aSpots, muse_geo_measurements_def) == CPL_ERROR_NONE,
                  CPL_ERROR_INCOMPATIBLE_INPUT);
  const unsigned char ifu = cpl_table_get_column_min(aSpots, "SubField"),
                      ifu2 = cpl_table_get_column_max(aSpots, "SubField");
  cpl_ensure_code(ifu == ifu2 && ifu >= 1 && ifu <= kMuseNumIFUs,
                  CPL_ERROR_ILLEGAL_INPUT);
  cpl_ensure_code(cpl_table_get_column_stdev(aSpots, "ScaleFOV") < 1e-10,
                  CPL_ERROR_ILLEGAL_INPUT);

  cpl_boolean verifydy = getenv("MUSE_DEBUG_GEO_VERIFY_DY")
                       && atoi(getenv("MUSE_DEBUG_GEO_VERIFY_DY")) > 0;
  if (verifydy) {
    cpl_msg_warning(__func__, "Running with DY pinhole distance verification on"
                    " (MUSE_DEBUG_GEO_VERIFY_DY=%s), will produce lots of files "
                    "\"centroids_d_*.dat\"!", getenv("MUSE_DEBUG_GEO_VERIFY_DY"));
  }

  /* extract a list of unique wavelengths from the spots table */
  double *lbda = cpl_table_get_data_double(aSpots, "lambda");
  cpl_vector *vlbda = cpl_vector_wrap(nrow, lbda);
  cpl_vector *lambdas = muse_cplvector_get_unique(vlbda);
  cpl_vector_unwrap(vlbda);
  int nlines = cpl_vector_get_size(lambdas);

  /* create array with likely size that may be needed *
   * (it will be enlarged or or trimmed as required)  */
  cpl_array *dy = cpl_array_new(kMuseSlicesPerCCD * nlines * kMuseCUmpmSpotsPerSlice,
                                CPL_TYPE_DOUBLE);

  /* loop through the table, do statistics on each spot  *
   * and record the vertical pinhole position difference *
   * for each doubly illuminated slice                   */
  unsigned short nslice;
  for (nslice = 1; nslice <= kMuseSlicesPerCCD; nslice++) {
    int iline;
    for (iline = 0; iline < nlines; iline++) {
      double lambda = cpl_vector_get(lambdas, iline);

      unsigned char nspot;
      double vposref = -DBL_MAX;
      for (nspot = 1; nspot <= kMuseCUmpmSpotsPerSlice; nspot++) {
        cpl_table *tspot = muse_geo_get_spot_peaks(aSpots, ifu, nslice, nspot,
                                                   lambda, vposref, verifydy, dy);
        cpl_table_delete(tspot);
      } /* for nspot (all spots) */
    } /* for iline (all wavelengths) */
  } /* for nslice (all slices) */
  cpl_vector_delete(lambdas);
  /* now resize the array to the real size needed and append it to the input array */
  muse_cplarray_erase_invalid(dy);
  cpl_msg_debug(__func__, "Median vertical pinhole distance in IFU %02hhu: %f mm",
                ifu, cpl_array_get_median(dy));
  #pragma omp critical (geo_dy_array_insert)
  cpl_array_insert(aDY, dy, cpl_array_get_size(aDY));
  cpl_array_delete(dy);

  return CPL_ERROR_NONE;
} /* muse_geo_compute_pinhole_local_distance() */

/*----------------------------------------------------------------------------*/
/**
  @brief  Use vertical pinhole distance measurements of all IFUs to compute the
          effective global value.
  @param  aDY      array containing all vertical pinhole position measurements
  @param  aWidth   bin width to use for the initial histogram
  @param  aMin     lower edge of the initial histogram
  @param  aMax     upper edge of the initial histogram
  @return The effective vertical pinhole distance in mm.

  This function does a simple statistical analysis of the single pinhole
  distance measurements in the input aDY array. First, to reject outliers, a
  histogram is built from aDY, using the input parameters aWidth, aMin, aMax.
  Any entry in aDY that is beyond a 1 bin-wide gap is rejected. Average value
  and standard deviation of the remaining entries determine the width of a
  second histogram (mean +/- 2 x sigma) which contains 20 bins. Again, entries
  beyond single-bin gaps are rejected before computing the final statistics.

  The average value computed like this is then displayed, and set in the
  environment variable MUSE_GEOMETRY_PINHOLE_DY, unless this environment
  variable already exists.

  @error{set CPL_ERROR_NULL_INPUT\, return 0., aDY is NULL}
  @error{set CPL_ERROR_INCOMPATIBLE_INPUT\, return 0.,
         aDY is not of type CPL_TYPE_DOUBLE}
  @error{set CPL_ERROR_ILLEGAL_INPUT\, return 0.,
         all entries in aDY are invalid}
 */
/*----------------------------------------------------------------------------*/
double
muse_geo_compute_pinhole_global_distance(cpl_array *aDY, double aWidth,
                                         double aMin, double aMax)
{
  cpl_ensure(aDY, CPL_ERROR_NULL_INPUT, 0.);
  cpl_ensure(cpl_array_get_type(aDY) == CPL_TYPE_DOUBLE,
             CPL_ERROR_INCOMPATIBLE_INPUT, 0.);
  cpl_ensure(cpl_array_count_invalid(aDY) < cpl_array_get_size(aDY),
             CPL_ERROR_ILLEGAL_INPUT, 0.);

  /* create a histogram and clean it */
  cpl_bivector *histogram = muse_cplarray_histogram(aDY, aWidth, aMin, aMax);
#if 0
  printf("aDY array 1: %f +/- %f (%f)\n", cpl_array_get_mean(aDY),
         cpl_array_get_stdev(aDY), cpl_array_get_median(aDY));
  cpl_array_dump(aDY, 0, 1000000, stdout);
  printf("aDY histogram 1:\n");
  cpl_plot_bivector(NULL, "w lp", NULL, histogram);
  cpl_bivector_dump(histogram, stdout);
  fflush(stdout);
#endif
  muse_cplarray_erase_outliers(aDY, histogram, 1, 0.5);
  cpl_bivector_delete(histogram);
  double mean = cpl_array_get_mean(aDY),
         stdev = cpl_array_get_stdev(aDY),
         min = mean - 2 * stdev,
         max = mean + 2 * stdev,
         step = (max - min) / 20.;
#if 0
  double median = cpl_array_get_median(aDY);
  printf("aDY array 2: %f +/- %f (%f)\n", mean, stdev, median);
  cpl_array_dump(aDY, 0, 1000000, stdout);
  fflush(stdout);
#endif
  histogram = muse_cplarray_histogram(aDY, step, min, max);
#if 0
  printf("aDY histogram 2:\n");
  cpl_plot_bivector(NULL, "w lp", NULL, histogram);
  cpl_bivector_dump(histogram, stdout);
  fflush(stdout);
#endif
  muse_cplarray_erase_outliers(aDY, histogram, 1, 0.5);
  cpl_bivector_delete(histogram);
  mean = cpl_array_get_mean(aDY);
  stdev = cpl_array_get_stdev(aDY);
#if 0
  double median2 = cpl_array_get_median(aDY);
  printf("aDY array 3: %f +/- %f (%f)\n", mean, stdev, median2);
  cpl_array_dump(aDY, 0, 1000000, stdout);
  fflush(stdout);
#endif

  /* print output and set the computed value in the environment, *
   * if the variable does not already exist                      */
  cpl_msg_info(__func__, "Computed vertical pinhole distance of %.6f +/- %.6f "
               "mm (instead of %.4f)", mean, stdev, kMuseCUmpmDY);
  if (getenv("MUSE_GEOMETRY_PINHOLE_DY")) {
    cpl_msg_warning(__func__, "Vertical pinhole distance already overridden in the "
                    "environment (%f mm)", atof(getenv("MUSE_GEOMETRY_PINHOLE_DY")));
  } else {
    char *envstring = cpl_sprintf("%f", mean);
    int err = setenv("MUSE_GEOMETRY_PINHOLE_DY", envstring, 1);
    if (!err) {
      cpl_msg_info(__func__, "Set MUSE_GEOMETRY_PINHOLE_DY=%s in the environment",
                   envstring);
    }
    cpl_free(envstring);
  }

  return mean;
} /* muse_geo_compute_pinhole_global_distance() */

/*----------------------------------------------------------------------------*/
/**
  @brief   Create a new MUSE geometry table.
  @param   aNRows   number of rows for the new table
  @param   aScale   scale to set in the new table
  @return  pointer to the new table

  Creates a new geometry table structure, calls muse_cpltable_new() to create
  the table component with all initial columns, and assigns the scale to the
  new object.
 */
/*----------------------------------------------------------------------------*/
muse_geo_table *
muse_geo_table_new(cpl_size aNRows, double aScale)
{
  muse_geo_table *gt = cpl_calloc(1, sizeof(muse_geo_table));
  gt->table = muse_cpltable_new(muse_geo_table_def, aNRows);
  gt->scale = aScale;
  return gt;
}

/*----------------------------------------------------------------------------*/
/**
  @brief   Make a copy of a MUSE geometry table.
  @param   aGeo   input MUSE geometry table
  @return  pointer to the new table, or NULL in case of NULL input

  Creates a new geometry table structure, calls cpl_table_duplicate() to
  duplicate the table component and assigns the scale to the new object.

  @error{set CPL_ERROR_NULL_INPUT\, return NULL, aGeo is NULL}
 */
/*----------------------------------------------------------------------------*/
muse_geo_table *
muse_geo_table_duplicate(const muse_geo_table *aGeo)
{
  cpl_ensure(aGeo, CPL_ERROR_NULL_INPUT, NULL);
  muse_geo_table *gt = cpl_calloc(1, sizeof(muse_geo_table));
  gt->table = cpl_table_duplicate(aGeo->table);
  gt->scale = aGeo->scale;
  return gt;
}

/*----------------------------------------------------------------------------*/
/**
  @brief  Deallocate memory associated to a geometry table object.
  @param  aGeo   input MUSE geometry table

  Calls cpl_table_delete() for the table component of of a muse_geo_table, and
  frees memory for the aPixtable pointer. As a safeguard, it checks if a valid
  pointer was passed, so that crashes cannot occur.
 */
/*----------------------------------------------------------------------------*/
void
muse_geo_table_delete(muse_geo_table *aGeo)
{
  if (!aGeo) {
    return;
  }
  cpl_table_delete(aGeo->table);
  aGeo->table = NULL;
  cpl_free(aGeo);
}

/*----------------------------------------------------------------------------*/
/**
  @brief  Use spot measurements to compute initial geometrical properties.
  @param  aSpots     table containing measurements of all spots
  @param  aTrace     tracing table
  @return a cpl_table * containing a partial per-wavelength geometry or NULL on
          error
  @remark The returned object has to be deallocated using cpl_table_delete().

  Loop over over all slices on the CCD, over all lines, and over all
  (kMuseCUmpmSpotsPerSlice) spots:
  first extract all entries for this slice/wavelength/spot from aSpots into a
  new table. Use the "flux" column to detect one or multiple peaks in the flux
  distribution of this spot over the exposure series (down to the 0.5 sigma
  level in terms of median deviation above the median but at most 500.). If
  multiple peaks are found, select the most central one in the series, or the
  one closest to the previous spot in the same slice at the same wavelength.
  Integrate the total flux of the selected peak and use it to create a weighted
  mean of the center of the spot (in x and y) and the centroid of the peak in
  the series, i.e. the centroid of the illumination depending on mask position.
  Propagate the interesting properties from aSpots to the output table.

  If the present spot is the last spot (the kMuseCUmpmSpotsPerSlice'th one) of
  this slice at this wavelength, compute the width, the horizontal shift
  relative to the pinhole mask, and the angle:
  The @em width of the slice can be computed from the known full width as
  determined by tracing at the vertical location of the spots. The distance
  between the spots in this slice gives a scale in mm / pix which can be used to
  compute the width of the slice in pix, using nominal scales for MUSE (for the
  sampling) and the VLT focal plane.
  The @em relative @em horizontal @em shift is simply the position of the spots
  relative to the slice center as determined by the trace table.  The @em angle
  is finally determined geometrically using the difference in peak positions and
  the known horizontal distance (kMuseCUmpmDX) between pinholes in the mask.

  Errors computed for these three properties are simple standard deviations of
  the values computed for the different spots in this slice at the same
  wavelength.

  The output table is in the format given by muse_geo_table_def, and is thought
  to be further processed by muse_geo_determine_horizontal().

  @error{set CPL_ERROR_NULL_INPUT\, return NULL, aSpots or aTrace are NULL}
  @error{set CPL_ERROR_ILLEGAL_INPUT\, return NULL,
         aSpots contains less than 10 rows}
  @error{set CPL_ERROR_INCOMPATIBLE_INPUT\, return NULL,
         aSpots does not have all the columns as defined by muse_geo_measurements_def}
  @error{set CPL_ERROR_ILLEGAL_INPUT\, return NULL,
         multiple or invalid sub-field number(s) found in input table}
  @error{set CPL_ERROR_ILLEGAL_INPUT\, return NULL,
         aSpots contains a range of values of ScaleFOV}
  @error{continue with next slice\, propagate error,
         tracing polynomials could not be constructed for a given slice}
  @error{output debug message\, skip the current slice\, continue with the next wavelength,
         combination of slice/wavelength/spot not found in aSpots}
  @error{output warning message\, skip the current slice\, continue with the next wavelength,
         detection of peaks fails for the current combination of slice/wavelength/spot}
  @error{output warning message\, skip the current slice\, continue with the next wavelength,
         all peaks found are too close to one end of the exposure series}
  @error{output warning message\, skip the current slice\, continue with the next wavelength,
         integrated flux is not positive}
  @error{output warning message\, do not record value in output table,
         computed property is outside reasonable range (between kMuseSliceLoLikelyWidth and kMuseSliceHiLikelyWidth for the width; below kMuseGeoSliceMaxAngle for the angle)}
 */
/*----------------------------------------------------------------------------*/
muse_geo_table *
muse_geo_determine_initial(cpl_table *aSpots, const cpl_table *aTrace)
{
  cpl_ensure(aSpots && aTrace, CPL_ERROR_NULL_INPUT, NULL);
  cpl_size nrow = cpl_table_get_nrow(aSpots);
  cpl_ensure(nrow > 10, CPL_ERROR_ILLEGAL_INPUT, NULL);
  cpl_ensure(muse_cpltable_check(aSpots, muse_geo_measurements_def) == CPL_ERROR_NONE,
             CPL_ERROR_INCOMPATIBLE_INPUT, NULL);
  const unsigned char ifu = cpl_table_get_column_min(aSpots, "SubField"),
                      ifu2 = cpl_table_get_column_max(aSpots, "SubField");
  cpl_ensure(ifu == ifu2 && ifu >= 1 && ifu <= kMuseNumIFUs,
             CPL_ERROR_ILLEGAL_INPUT, NULL);
  const double kScale = cpl_table_get_column_mean(aSpots, "ScaleFOV");
  cpl_ensure(cpl_table_get_column_stdev(aSpots, "ScaleFOV") < 1e-10,
             CPL_ERROR_ILLEGAL_INPUT, NULL);

  double maskangle = 0., fmaskrot = 1.;
  if (getenv("MUSE_GEOMETRY_MASK_ROTATION")) {
    maskangle = atof(getenv("MUSE_GEOMETRY_MASK_ROTATION"));
    fmaskrot = cos(maskangle * CPL_MATH_RAD_DEG);
    cpl_msg_warning(__func__, "Adapting to global mask rotation of %.4f deg "
                    "(cos = %.4e)", maskangle, fmaskrot);
  }
  double pinholedy = kMuseCUmpmDY;
  if (getenv("MUSE_GEOMETRY_PINHOLE_DY")) {
    pinholedy = atof(getenv("MUSE_GEOMETRY_PINHOLE_DY"));
    cpl_msg_info(__func__, "Using pinhole y distance of %f mm (instead of "
                 "%f mm)", pinholedy, kMuseCUmpmDY);
  }

  /* extract a list of unique wavelengths from the spots table */
  double *lbda = cpl_table_get_data_double(aSpots, "lambda");
  cpl_vector *vlbda = cpl_vector_wrap(nrow, lbda);
  cpl_vector *lambdas = muse_cplvector_get_unique(vlbda);
  cpl_vector_unwrap(vlbda);
  int nlines = cpl_vector_get_size(lambdas);
  muse_geo_table *gt = muse_geo_table_new(kMuseSlicesPerCCD
                                          * kMuseCUmpmSpotsPerSlice * nlines,
                                          kScale);
  /* loop through the table, do statistics on each spot (position vs flux) */
  cpl_size irrow = 0; /* row in gt->table */
  unsigned short nslice;
  for (nslice = 1; nslice <= kMuseSlicesPerCCD; nslice++) {
    cpl_polynomial **ptrace = muse_trace_table_get_polys_for_slice(aTrace,
                                                                   nslice);
    if (!ptrace) {
      cpl_msg_debug(__func__, "Skipping IFU %2hhu SliceCCD %2d", ifu, nslice);
      continue;
    }
    cpl_msg_info(__func__, "Handling IFU %2hhu SliceCCD %2d", ifu, nslice);
    int iline;
    for (iline = 0; iline < nlines; iline++) {
      double lambda = cpl_vector_get(lambdas, iline);

      unsigned char nspot, nslicespot = 0;
      double vposref = -DBL_MAX;
      for (nspot = 1; nspot <= kMuseCUmpmSpotsPerSlice; nspot++) {
        cpl_table *tspot = muse_geo_get_spot_peaks(aSpots, ifu, nslice, nspot,
                                                   lambda, vposref, CPL_FALSE,
                                                   NULL);
        if (!tspot) {
          break;
        }
        nslicespot++;
        double xcenter = 0., ycenter = 0., /* properties for weighted centroids */
               vpos = 0., ftot = 0.;
        nrow = cpl_table_get_nrow(tspot);
        cpl_size irow;
        for (irow = 0; irow < nrow; irow++) {
          double flux = cpl_table_get(tspot, "flux", irow, NULL);
          ftot += flux;
          xcenter += cpl_table_get(tspot, "xc", irow, NULL) * flux;
          ycenter += cpl_table_get(tspot, "yc", irow, NULL) * flux;
          vpos += cpl_table_get(tspot, "VPOS", irow, NULL) * flux;
        } /* for irow (tspot table rows) */
        cpl_table_delete(tspot);
        if (ftot <= 0.) {
          cpl_msg_warning(__func__, "Invalid integrated flux of spot %1hhu/%1hhu "
                          "in slice %2hu of IFU %2hhu at wavelength %.3f: %e",
                          nspot, nslicespot, nslice, ifu, lambda, ftot);
          break;
        }
        xcenter /= ftot;
        ycenter /= ftot;
        vpos /= ftot;
        if (vposref < 0) {
          /* remember the peak center of this spot as reference for the others */
          vposref = vpos;
        }
        double xcen = cpl_polynomial_eval_1d(ptrace[MUSE_TRACE_CENTER], ycenter,
                                             NULL),
               xl = cpl_polynomial_eval_1d(ptrace[MUSE_TRACE_LEFT], ycenter, NULL),
               xr = cpl_polynomial_eval_1d(ptrace[MUSE_TRACE_RIGHT], ycenter, NULL),
               xwidth = xr - xl;
        cpl_msg_debug(__func__, "IFU %2hhu SliceCCD %2d spot %1hhu/%1hhu lambda %.3f "
                      "x/y %8.3f %8.3f (xcen %8.3f xwidth %6.3f) vpos %f flux %e",
                      ifu, nslice, nspot, nslicespot, lambda, xcenter, ycenter,
                      xcen, xwidth, vpos, ftot);
        cpl_table_set_int(gt->table, MUSE_GEOTABLE_FIELD, irrow, ifu);
        cpl_table_set_int(gt->table, MUSE_GEOTABLE_SKY, irrow,
                          kMuseGeoSliceSky[nslice - 1]);
        cpl_table_set_int(gt->table, MUSE_GEOTABLE_CCD, irrow, nslice);
        cpl_table_set_int(gt->table, "spot", irrow, nslicespot);
        /* follow the optical numbering, with "reversed" numbering, *
         * see VLT-TRE-MUS-14670-0657 v1.06, Sect. 1.4.2.2.3        */
        unsigned char stack = nslice <= 12 ? 4 : (nslice <= 24 ? 3 : (nslice <= 36 ? 2 : 1));
        cpl_table_set_int(gt->table, "stack", irrow, stack);
        /* don't set "spot" column here, see below */
        cpl_table_set_double(gt->table, "xc", irrow, xcenter);
        cpl_table_set_double(gt->table, "yc", irrow, ycenter);
        cpl_table_set_double(gt->table, "dxl", irrow, xcenter - xl);
        cpl_table_set_double(gt->table, "dxr", irrow, xr - xcenter);
        cpl_table_set_double(gt->table, "vpos", irrow, vpos);
        cpl_table_set_double(gt->table, "flux", irrow, ftot);
        cpl_table_set_double(gt->table, "lambda", irrow, lambda);
        /* set width and angle columns invalid at the beginning, *
         * to be able to select invalid table entries            */
        cpl_table_set_invalid(gt->table, MUSE_GEOTABLE_WIDTH, irrow);
        cpl_table_set_invalid(gt->table, MUSE_GEOTABLE_ANGLE, irrow);

        /* use data of all three spots in this slice to compute *
         * relative width, angle, and horizontal position       */
        if (nslicespot == kMuseCUmpmSpotsPerSlice) {
          /* get data of the previous two spots back from the table */
          int err1a, err2a, err1b, err2b;
          double xc1 = cpl_table_get_double(gt->table, "xc", irrow - 2, &err1a),
                 xc2 = cpl_table_get_double(gt->table, "xc", irrow - 1, &err2a),
                 vpos1 = cpl_table_get_double(gt->table, "vpos", irrow - 2, &err1b),
                 vpos2 = cpl_table_get_double(gt->table, "vpos", irrow - 1, &err2b);
          if (!err1a && !err2a) {
            if (xcenter < xc2 || xc2 < xc1) {
              cpl_msg_warning(__func__, "spots are not sorted left-to-right on "
                              "the CCD (%f .. %f .. %f)!", xc1, xc2, xcenter);
            }
            double dx = (xcenter - xc1) / 2.;
            /* a kind of standard deviation of the three values:   *
             * dx, dx1, dx2; the term for dx itself is always zero */
            double dxerr = sqrt((pow(xcenter - xc2 - dx, 2)
                                 + pow(xc2 - xc1 - dx, 2)) / 3.);
            /* See if the error is high, as this means that one of the three  *
             * positions and hence of the two distances is likely wrong.      *
             * Then use an estimate of what the distance should really be to  *
             * decide which of the two distances is correct.                  */
            /* XXX this is ugly, as dxexpected may be a bad estimate,    *
             *     derived using the data of April 2013 with only 6 IFUs */
            double xreloffset = 0.;
            if (dxerr > 0.3) {
              const double dxexpected = 26.04644 - 0.05537208 * ifu;
              double dx1 = xc2 - xc1,
                     dx2 = xcenter - xc2,
                     diff1 = fabs(dx1 - dxexpected),
                     diff2 = fabs(dx2 - dxexpected),
                     dxnew;
              if (fmin(diff1, diff2) > 1.5) { /* more than 1.5 pix difference! */
                dxnew = 99.; /* set to large value to trigger the bad width case below */
              } else if (diff2 > diff1) {
                dxnew = dx1;
              } else {
                dxnew = dx2;
              }
              /* correct the xrel to be computed below, too, by half *
               * the difference in width that this change makes      */
              xreloffset = xwidth * kMuseCUmpmDX / fmaskrot
                         * kMuseTypicalCubeSizeX * kScale / 60.
                         * fabs(1. / dx - 1 / dxnew) / 2.;
              cpl_msg_debug(__func__, "IFU %2hhu SliceCCD %2d spot %1hhu/%1hhu "
                            "lambda %.3f dx %.3f +/- %.3f (%.3f %.3f): dxerr "
                            "is large, using a guess of %.3f +/- %.3f "
                            "(expected was %.3f for this IFU), adding %.3f to"
                            " xrel!", ifu, nslice, nspot, nslicespot, lambda,
                            dx, dxerr, xc2-xc1, xcenter-xc2, dxnew, dxerr / 2.,
                            dxexpected, xreloffset);
              dx = dxnew;
              dxerr /= 2.; /* hopefully we are not a factor of two less wrong! */
            } /* if dxerr > 0.3 */
            cpl_table_fill_column_window_double(gt->table, "dx", irrow - 2, 3, dx);
            cpl_table_fill_column_window_double(gt->table, "dxerr", irrow - 2, 3, dxerr);
            /* Project the size back to the focal plane and use the known      *
             * scales to compute the effective slice width in nominal pixels.  *
             * Strictly, one should take the slice width from the trace at the *
             * center of the three y values, but the difference is negligible. */
            double scale = kMuseCUmpmDX / fmaskrot / dx, /* scale of this slice [mm / pix] */
                   width = xwidth * scale * kMuseTypicalCubeSizeX * kScale / 60.,
                   werr = width * dxerr / dx; /* error estimate */
            if (width > kMuseSliceLoLikelyWidth && width < kMuseSliceHiLikelyWidth) {
              cpl_table_fill_column_window_double(gt->table, MUSE_GEOTABLE_WIDTH,
                                                  irrow - 2, 3, width);
              cpl_table_fill_column_window_double(gt->table, MUSE_GEOTABLE_WIDTH"err",
                                                  irrow - 2, 3, werr);
            } else {
              cpl_msg_info(__func__, "IFU %2hhu slice %2d lambda %.3f: computed "
                              "an unlikely width: %.3f +/- %.3f (hope for other"
                              " wavelengths!)", ifu, nslice, lambda, width, werr);
              cpl_table_set_column_invalid(gt->table, MUSE_GEOTABLE_WIDTH,
                                           irrow - 2, 3);
              cpl_table_set_column_invalid(gt->table, MUSE_GEOTABLE_WIDTH"err",
                                           irrow - 2, 3);
            }

            /* now compute relative horizontal positions against the center *
             * of the slice, in mm in the focal plane, for all three spots  */
            double xrel = (xcen - xc1 + xreloffset) * scale,
                   xrelerr = fabs(xrel * dxerr / dx);
            cpl_table_set_double(gt->table, "xrel", irrow - 2, xrel);
            cpl_table_set_double(gt->table, "xrelerr", irrow - 2, xrelerr);
            xrel = (xcen - xc2 + xreloffset) * scale;
            xrelerr = fabs(xrel * dxerr / dx);
            cpl_table_set_double(gt->table, "xrel", irrow - 1, xrel);
            cpl_table_set_double(gt->table, "xrelerr", irrow - 1, xrelerr);
            xrel = (xcen - xcenter + xreloffset) * scale;
            xrelerr = fabs(xrel * dxerr / dx);
            cpl_table_set_double(gt->table, "xrel", irrow, xrel);
            cpl_table_set_double(gt->table, "xrelerr", irrow, xrelerr);
          } /* else: got all x-pos measurements */

          if (!err1b && !err2b) { /* compute angle in degrees */
            /* check if the vpos values correspond to the same flux peak or *
             * if we need to fix them up for the vertical pinhole distance  */
            double pdiff = fmax(vpos1, fmax(vpos2, vpos))
                         - fmin(vpos1, fmin(vpos2, vpos));
            if (pdiff > 0.5) { /* max diff. 0.5 arcsec =~ 0.3 mm */
              double pmean = (vpos1 + vpos2 + vpos) / 3.;
              if (vpos1 > pmean) { /* subtract pinhole distance from high values */
                vpos1 -= pinholedy * fmaskrot;
              }
              if (vpos2 > pmean) {
                vpos2 -= pinholedy * fmaskrot;
              }
              if (vpos > pmean) {
                vpos -= pinholedy * fmaskrot;
              }
              double pdiff2 = fmax(vpos1, fmax(vpos2, vpos))
                            - fmin(vpos1, fmin(vpos2, vpos));
              if (pdiff2 < pdiff) {
                cpl_msg_debug(__func__, "Fixed max vpos diff from %f down to %f",
                              pdiff, pdiff2);
              } else { /* didn't work, copy the old values */
                double vpos1o = cpl_table_get_double(gt->table, "vpos", irrow - 2, NULL),
                       vpos2o = cpl_table_get_double(gt->table, "vpos", irrow - 1, NULL),
                       vpos3o = cpl_table_get_double(gt->table, "vpos", irrow, NULL);
                cpl_msg_info(__func__, "Large max vpos diff detected but "
                             "not fixed! (original: %f %f %f, %f -> mean %f "
                             "-> fixed: %f %f %f, %f)", vpos1o, vpos2o, vpos3o,
                             pdiff, pmean, vpos1, vpos2, vpos, pdiff2);
                vpos1 = vpos1o;
                vpos2 = vpos2o;
                vpos = vpos3o;
              }
            } /* if large vpos differences */

            /* the angles computed here need to be inverted */
            double f = 1. / kMuseCUmpmDX / fmaskrot, /* 1 / distance */
                   angle1 = -atan((vpos2 - vpos1) * f) * CPL_MATH_DEG_RAD,
                   angle2 = -atan((vpos - vpos2) * f) * CPL_MATH_DEG_RAD,
                   angle = (angle1 + angle2) / 2.;
            /* simple-minded standard deviation again */
            double aerr = sqrt((pow(angle1 - angle, 2) + pow(angle2 - angle, 2)) / 3.);
            if (fabs(angle) < kMuseGeoSliceMaxAngle) {
              cpl_table_fill_column_window_double(gt->table, MUSE_GEOTABLE_ANGLE,
                                                  irrow - 2, 3, angle);
              cpl_table_fill_column_window_double(gt->table, MUSE_GEOTABLE_ANGLE"err",
                                                  irrow - 2, 3, aerr);
            } else {
              cpl_msg_info(__func__, "IFU %2hhu slice %2d lambda %.3f: computed"
                           " an unlikely angle: %.3f +/- %.3f (hope for other "
                           "wavelengths!)", ifu, nslice, lambda, angle, aerr);
              cpl_table_set_column_invalid(gt->table, MUSE_GEOTABLE_ANGLE,
                                           irrow - 2, 3);
              cpl_table_set_column_invalid(gt->table, MUSE_GEOTABLE_ANGLE"err",
                                           irrow - 2, 3);
            }
          } /* else: got all vpos measurements */

          /* and get the dxl value of the left-most spot and the dxr *
           * value of the right-most spot and set them for all spots */
          double dx = cpl_table_get_double(gt->table, "dx", irrow - 1, NULL),
                 dxl = cpl_table_get_double(gt->table, "dxl", irrow - 2, NULL),
                 dxr = cpl_table_get_double(gt->table, "dxr", irrow, NULL);
          cpl_table_fill_column_window_double(gt->table, "dxl", irrow - 2, 3, dxl / dx);
          cpl_table_fill_column_window_double(gt->table, "dxr", irrow - 2, 3, dxr / dx);
        } /* if nslicespot == kMuseCUmpmSpotsPerSlice (last spot in slice) */

        irrow++;
      } /* for nspot (all spots) */
    } /* for iline (all wavelengths) */
    muse_trace_polys_delete(ptrace);
  } /* for nslice (all slices) */
  cpl_vector_delete(lambdas);
  cpl_table_set_size(gt->table, irrow); /* remove empty rows */
  /* erase rows which only got a partial analysis (the |break|s above) */
  cpl_table_and_selected_invalid(gt->table, MUSE_GEOTABLE_WIDTH);
  cpl_table_or_selected_invalid(gt->table, MUSE_GEOTABLE_ANGLE);
  cpl_table_erase_selected(gt->table);
  return gt;
} /* muse_geo_determine_initial() */

/*----------------------------------------------------------------------------*/
/**
  @brief  Interatively reject outliers and compute (weighted) statistics of
          intermediate geometry table columns.
  @param  aSlice    table which contains the data to use
  @param  aCol      relevant data column in the table
  @param  aColErr   error column related to the data column in the same table,
                    can be NULL to not weight the data
  @param  aValue    value to return
  @param  aError    sigma error value to return, can be NULL
  @param  aMedian   median value to be returned, can be NULL to skip median
                    computation
  @param  aSigma    sigma level to use for iterative rejection
  @return number of rejected elements in input table or -1 on error

  To be used from muse_geo_determine_horizontal().

  @error{return -1, aSlice\, aCol\, or aValue are NULL}
  @error{return -1, cannot get data for column aCol from aSlice}
 */
/*----------------------------------------------------------------------------*/
static cpl_size
muse_geo_determine_horizontal_wmean(const cpl_table *aSlice,
                                    const char *aCol, const char *aColErr,
                                    double *aValue, double *aError,
                                    double *aMedian, double aSigma)
{
  const char func[] = "muse_geo_determine_horizontal"; /* pretend to be there */
  if (!aSlice) return -1;
  if (!aCol || !aValue) return -1;

  /* first create vector(s) from the relevant table columns */
  const double *vv = cpl_table_get_data_double_const(aSlice, aCol),
               *ve = NULL;
  if (aColErr) {
    ve = cpl_table_get_data_double_const(aSlice, aColErr);
  }
  if (!vv) return -1;
  /* compute median and median absolute deviation to get starting limits */
  cpl_size n = cpl_table_get_nrow(aSlice);
  cpl_vector *vtmp = cpl_vector_wrap(n, (double *)cpl_table_get_data_double_const(aSlice, aCol));
  double median = cpl_table_get_column_median(aSlice, aCol),
         mdev = muse_cplvector_get_adev_const(vtmp, median),
         vlo = median - aSigma * mdev,
         vhi = median + aSigma * mdev;
  cpl_vector_unwrap(vtmp);
  cpl_msg_debug(func, "%s/%s: median %f +/- %f --> %f...%f", aCol, aColErr,
                median, mdev, vlo, vhi);

  /* iterate: compute weighted mean, using only entries *
   * within the computed boundaries                     */
  double value, sigma = mdev,
         min, max;
  cpl_vector *vmedian = NULL;
  cpl_size nrejected;
  do {
    min = DBL_MAX;
    max = -DBL_MAX;
    if (aMedian) {
      vmedian = cpl_vector_new(n);
    }
    double weight = 0.;
    value = 0.;
    nrejected = 0;
    cpl_size i, im = 0;
    for (i = 0; i < n; i++) {
      if (vv[i] < vlo || vv[i] > vhi) { /* skip this entry */
        nrejected++;
        continue;
      }
      if (ve) {
        value += vv[i] / ve[i];
        weight += 1. / ve[i];
      } else {
        value += vv[i];
        weight += 1.;
      }
      if (vv[i] < min) {
        min = vv[i];
      }
      if (vv[i] > max) {
        max = vv[i];
      }
      if (aMedian) {
        cpl_vector_set(vmedian, im++, vv[i]);
      }
    } /* for i (slice table rows) */
    value /= weight;
    double wmse = 0.; /* compute weighted MSE */
    for (i = 0; i < n; i++) {
      if (vv[i] < vlo || vv[i] > vhi) {
        continue;
      }
      if (ve) {
        wmse += pow(vv[i] - value, 2) / ve[i];
      } else {
        wmse += pow(vv[i] - value, 2);
      }
#if 0
      if (aMedian) {
        cpl_msg_debug("vpos", "%f %f %f -> %f", vv[i], vv[i] - value,
                      pow(vv[i] - value, 2), wmse);
      }
#endif
    } /* for i (slice table rows) */
    wmse /= weight;
    if (aMedian) {
      cpl_vector_set_size(vmedian, im); /* delete unused entries */
      *aMedian = cpl_vector_get_median(vmedian);
      cpl_vector_delete(vmedian);
    }
    /* compute sigma (if we have a valid result) */
    if (isnormal(weight) && isfinite(wmse)) {
      /* full propagated errors: direct variances plus the weighted MSE */
      /* XXX where did this formula originally come from?!? */
      sigma = sqrt(ve ? (n - nrejected) / (weight*weight) : 0. + wmse);
#if 0
      if (aMedian) {
        cpl_msg_debug("vpos", "%f", sigma);
      }
#endif
    } /* if */
    /* compute the new limits (if we weighted any points) */
    if (isfinite(value)) {
      vlo = value - aSigma * sigma;
      vhi = value + aSigma * sigma;
    } /* if */
#if 0
    cpl_msg_debug(func, "%s/%s: %f +/- %f (+/- %f) --> %f...%f (rej. %"
                  CPL_SIZE_FORMAT" / %"CPL_SIZE_FORMAT")", aCol, aColErr,
                  value, sqrt(wmse), sigma, vlo, vhi, nrejected, n);
#endif
  } while (nrejected < n && (max > vhi || min < vlo));

  /* last resort, in case all points got rejected: re-use the   *
   * previous ranges (they were then not overwritten above) and *
   * use those as central/final value and their sigma           */
  if (nrejected == n) {
    value = (vlo + vhi) / 2.;
    sigma = (vhi - vlo) / (2. * aSigma);
#if 0
    cpl_msg_debug(func, "%s/%s: %f +/- %f (ALL rej. %"CPL_SIZE_FORMAT" / %"
                  CPL_SIZE_FORMAT")", aCol, aColErr, value, sigma, nrejected, n);
#endif
  }

  *aValue = value;
  if (aError) {
    *aError = sigma;
  }
  return nrejected;
} /* muse_geo_determine_horizontal_wmean() */

/*----------------------------------------------------------------------------*/
/**
  @brief  Compute properly weighted vertical slice position mean from
          intermediate geometry table columns.
  @param  aSlice    table which contains the data to use
  @param  aP        mean value to return
  @param  aPE       standard deviation to return
  @param  aPM       median value to return
  @param  aDY       vertical pinhole distance to use
  @param  aF        inversion factor to use

  This computes the statistics of the "vpos" data in the table, and tries to
  fix it, in case the standard deviation is found to be too large (> 0.01). The
  fix is either to offset part of the data by one vertical pinhole distance
  (aDY), or if that doesn't work, to iteratively reject outliers using
  muse_geo_determine_horizontal_wmean().

  To be used from muse_geo_determine_horizontal().

  @error{return, aSlice\, aP\, aPE\, or aPM are NULL}
 */
/*----------------------------------------------------------------------------*/
static void
muse_geo_determine_horizontal_vpos(const cpl_table *aSlice,
                                   double *aP, double *aPE, double *aPM,
                                   double aDY, double aF)
{
  const char func[] = "muse_geo_determine_horizontal"; /* pretend to be there */
  if (!aSlice) return;
  if (!aP || !aPE || !aPM) return;

  cpl_vector *vvpos = cpl_vector_wrap(cpl_table_get_nrow(aSlice),
                                      (double *)cpl_table_get_data_double_const(aSlice,
                                                                                "vpos"));
  *aP = cpl_vector_get_mean(vvpos);
  *aPE = cpl_vector_get_stdev(vvpos);
  *aPM = cpl_vector_get_median_const(vvpos);
  /* if the spread is large, then there might be spots *
   * from different pinholes involved!                 */
  if (*aPE < 0.01) { /* this should already be good enough */
    cpl_vector_unwrap(vvpos);
    return;
  } /* if */

  cpl_size n = cpl_vector_get_size(vvpos);
  cpl_vector *vpp = cpl_vector_duplicate(vvpos),
             *vres = cpl_vector_duplicate(vvpos),
             *vmedian = cpl_vector_new(n);
  cpl_vector_unwrap(vvpos);
  cpl_vector_fill(vmedian, *aPM);
  cpl_vector_subtract(vres, vmedian);
  cpl_vector_delete(vmedian);
  double min = cpl_vector_get_min(vres),
         max = cpl_vector_get_max(vres);
  cpl_msg_debug(func, "vector of vpos values (%.4f +/- %.4f, %.4f) and "
                "residuals (%.4f ... %.4f), pinhole distance %.4f",
                *aP, *aPE, *aPM, min, max, aDY * aF);
#if 0
  cpl_bivector *biv = cpl_bivector_wrap_vectors(vpp, vres);
  cpl_bivector_dump(biv, stdout);
  fflush(stdout);
  cpl_bivector_unwrap_vectors(biv);
#endif
  /* Also check the absolute residuals. This case is useful, when  *
   * half of the value happen to be above and half below the mean, *
   * by approximately one pinhole distance -> halfandhalf = true!  */
  cpl_array *ares = cpl_array_wrap_double(cpl_vector_unwrap(vres), n);
  cpl_array_abs(ares);
  double amin = cpl_array_get_min(ares),
         amax = cpl_array_get_max(ares);
  cpl_boolean halfandhalf = fabs(aDY/2. - amin) < 0.01
                          && fabs(aDY/2. - amax) < 0.01;
  cpl_array_delete(ares);

  double p2, pe2, pm2;
  cpl_size i;
  double limit = fabs(0.9 * aDY * aF);
  if (!halfandhalf && /* halfandhalf should be rectified by the else case */
      fabs(min) < limit && fabs(max) < limit) {
    /* If the largest residuals are less than 90% of the pinhole distance *
     * then shifting by one pinhole will not help. Then just compute mean *
     * and error, clipping the outliers as for the other properties.      */
    cpl_size nrej = muse_geo_determine_horizontal_wmean(aSlice, "vpos", NULL,
                                                        &p2, &pe2, &pm2, 2.);
    cpl_msg_debug(func, "values after rejection vector of vpos values (%.4f "
                  "+/- %.4f, %.4f), %"CPL_SIZE_FORMAT" rejected", p2, pe2, pm2,
                  nrej);
  } else { /* Largest residual is of the order of the pinhole distance.   *
            * Try to fix that, by looping through the vector and subtract *
            * the pinhole distance from the high values.                  */
    cpl_size nfixed = 0;
    for (i = 0; i < n; i++) {
      double v = cpl_vector_get(vpp, i);
      if (v > *aP) { /* subtract from all values greater than the mean */
        cpl_vector_set(vpp, i, v - aDY * aF);
        nfixed++;
      } /* if */
    } /* for i */
    p2 = cpl_vector_get_mean(vpp);
    pe2 = cpl_vector_get_stdev(vpp);
    pm2 = cpl_vector_get_median_const(vpp);
    cpl_msg_debug(func, "fixed vector of vpos values (%.4f +/- %.4f, %.4f), "
                  "%"CPL_SIZE_FORMAT" fixed", p2, pe2, pm2, nfixed);
#if 0
    cpl_vector_dump(vpp, stdout);
    fflush(stdout);
#endif
  } /* else */
  cpl_vector_delete(vpp);
  if (pe2 < *aPE) { /* if improved, keep these values */
    *aP = p2;
    *aPE = pe2;
    *aPM = pm2;
  }
} /* muse_geo_determine_horizontal_vpos() */

/*----------------------------------------------------------------------------*/
/**
  @brief  Use per-spot and per-wavelength partial geometry to determine the
          horizontal geometrical properties for each slice.
  @param  aGeo   partially filled geometry table containing pre-processed
                 results of all spots
  @return a cpl_table * containing a partial output geometry or NULL on error

  The input table aGeo is thought to be produced by
  muse_geo_determine_initial().  From aGeo extract those entries with "spot" = 2
  into the result table.

  Loop over over all slices on the CCD and extract all entries belonging to this
  SliceCCD number into a temporary table. Loop over this table to compute
  weighted averages for the angle, width, and relative horizontal shift, plus
  unweighted average and standard deviation of the peak positions, in effect
  averaging over all measurements at all wavelengths. Save these properties and
  their errors into the columns MUSE_GEOTABLE_ANGLE, MUSE_GEOTABLE_WIDTH,
  "xrel", and  "vpos" (with the errors in the corresponding columns with "err"
  appended). These entries replace the original entries in the result table.

  Next, loop over all rows of slices. Use the relative horizontal shift and the
  slice width to compute the gap between slices in this slicer row, starting
  with the central gap. Together with the width this gives the horizontal
  center of each slice in the field of view, which is saved in the
  MUSE_GEOTABLE_X of the result table. Errors are propagated from the
  individual measurements and saved in the MUSE_GEOTABLE_X"err" column. If a
  gap has an unlikely width (either negative or above 0.5 pix), the gap is
  reset to a standard width and the square root of the previously computed gap
  is added onto its error; a warning message is printed.

  If the environment variable MUSE_GEOMETRY_STD_GAP is set to a positive
  integer, it will use an older way to compute the gaps.

  Further process the output table with muse_geo_determine_vertical() to derive
  the full geometry.

  @error{set CPL_ERROR_NULL_INPUT\, return NULL, aGeo or aGeo->table is NULL}
  @error{set CPL_ERROR_ILLEGAL_INPUT\, return NULL,
         aGeo contains less than 50 rows}
  @error{set CPL_ERROR_INCOMPATIBLE_INPUT\, return NULL,
         aGeo does not have all the columns as defined by muse_geo_table_def}
  @error{set CPL_ERROR_ILLEGAL_INPUT\, return NULL,
         multiple or invalid sub-field number(s) found in input table}
  @error{continue with next slice, spot 2 of a given slice not present in aGeo}
  @error{continue with next row of slices,
         one of the central slices are not present in the input table aGeo for a slicer row}
  @error{skip position computation for this slice,
         one of the outer slices are not present in the input table aGeo for a slicer row}
 */
/*----------------------------------------------------------------------------*/
muse_geo_table *
muse_geo_determine_horizontal(const muse_geo_table *aGeo)
{
  cpl_ensure(aGeo && aGeo->table, CPL_ERROR_NULL_INPUT, NULL);
  cpl_size nrow = cpl_table_get_nrow(aGeo->table);
  cpl_ensure(nrow >= 50, CPL_ERROR_ILLEGAL_INPUT, NULL);
  cpl_ensure(muse_cpltable_check(aGeo->table, muse_geo_table_def) == CPL_ERROR_NONE,
             CPL_ERROR_INCOMPATIBLE_INPUT, NULL);
  const unsigned char ifu = cpl_table_get_column_min(aGeo->table, MUSE_GEOTABLE_FIELD),
                      ifu2 = cpl_table_get_column_max(aGeo->table, MUSE_GEOTABLE_FIELD);
  cpl_ensure(ifu == ifu2 && ifu >= 1 && ifu <= kMuseNumIFUs,
             CPL_ERROR_ILLEGAL_INPUT, NULL);

  double maskangle = 0., fmaskrot = 1.;
  if (getenv("MUSE_GEOMETRY_MASK_ROTATION")) {
    maskangle = atof(getenv("MUSE_GEOMETRY_MASK_ROTATION"));
    fmaskrot = cos(maskangle * CPL_MATH_RAD_DEG);
    cpl_msg_warning(__func__, "Adapting to global mask rotation of %.4f deg "
                    "(cos = %.4e)", maskangle, fmaskrot);
  }
  double pinholedy = kMuseCUmpmDY;
  if (getenv("MUSE_GEOMETRY_PINHOLE_DY")) {
    pinholedy = atof(getenv("MUSE_GEOMETRY_PINHOLE_DY"));
    cpl_msg_info(__func__, "Using pinhole y distance of %f mm (instead of "
                 "%f mm)", pinholedy, kMuseCUmpmDY);
  }
  cpl_boolean stdgap = getenv("MUSE_GEOMETRY_STD_GAP")
                     && atoi(getenv("MUSE_GEOMETRY_STD_GAP")) > 0;
  if (stdgap) {
    cpl_msg_warning(__func__, "Using old (standard) gap computation");
  } else {
    cpl_msg_info(__func__, "Using new (alternative) gap computation");
  }
  const double kScaleX = kMuseTypicalCubeSizeX * aGeo->scale / 60.;

  /* select the middle spot, to get per-slice properties */
  muse_geo_table *gt = muse_geo_table_duplicate(aGeo);
  cpl_table_and_selected_int(gt->table, "spot", CPL_NOT_EQUAL_TO, 2);
  cpl_table_erase_selected(gt->table);
  /* now results for different wavelengths are adjacent */
#if 0
  FILE *f = fopen("bla_horizontal.dat", "w");
  fprintf(f, "#");
  cpl_table_dump(gt->table, 0, 100000000, f);
  fclose(f);
  f = fopen("bla_lambdas.dat", "w");
  fprintf(f, "#");
  cpl_table_dump(aGeo->table, 0, 100000000, f);
  fclose(f);
#endif
  unsigned short nslice;
  for (nslice = 1; nslice <= kMuseSlicesPerCCD; nslice++) {
    /* get the entries for this one slice */
    cpl_table_select_all(gt->table);
    cpl_table_and_selected_int(gt->table, "SliceCCD", CPL_EQUAL_TO, nslice);
    if (cpl_table_count_selected(gt->table) < 1) {
      continue;
    }
    cpl_array *asel = cpl_table_where_selected(gt->table);
    cpl_table *slice = cpl_table_extract_selected(gt->table);
#if 0
    cpl_table_dump(slice, 0, 1000, stdout);
    fflush(stdout);
#endif

    /* compute weighted averages for the geometrical properties */
    double a, ae, w, we, xr, xre, dxl = 0., dxr = 0.;
    muse_geo_determine_horizontal_wmean(slice, MUSE_GEOTABLE_ANGLE,
                                        MUSE_GEOTABLE_ANGLE"err", &a, &ae,
                                        NULL, 2.);
    muse_geo_determine_horizontal_wmean(slice, MUSE_GEOTABLE_WIDTH,
                                        MUSE_GEOTABLE_WIDTH"err", &w, &we,
                                        NULL, 1.5);
    muse_geo_determine_horizontal_wmean(slice, "xrel", "xrelerr", &xr, &xre,
                                        NULL, 1.5);
    muse_geo_determine_horizontal_wmean(slice, "dxl", NULL, &dxl, NULL, NULL, 2.);
    muse_geo_determine_horizontal_wmean(slice, "dxr", NULL, &dxr, NULL, NULL, 2.);

    /* compute the average +/- stdev of the "vpos" values, too */
    cpl_errorstate ps = cpl_errorstate_get();
    double p = NAN, pe = -1, pm = NAN;
    muse_geo_determine_horizontal_vpos(slice, &p, &pe, &pm,
                                       pinholedy, fmaskrot);
    if (pe < 0 && !cpl_errorstate_is_equal(ps)) { /* some error, possibly only a single value */
      pe = 0.1; /* better something large than something negative... */
      cpl_errorstate_set(ps);
    }

    cpl_msg_debug(__func__, "IFU %2hhu stack %1d slice %2d / %2d "
                  "angle %6.3f +/- %.3f deg width %.3f +/- %.3f pix "
                  "xrel %.4f +/- %.4f vpos %.4f +/- %.4f (%.4f)", ifu,
                  cpl_table_get_int(slice, "stack", 0, NULL), nslice,
                  cpl_table_get_int(slice, "SliceSky", 0, NULL), a, ae, w, we,
                  xr, xre, p, pe, pm);
    /* replace first selected line with the compute properties, *
     * unselect it then erase the other selected ones           */
    cpl_size irow = cpl_array_get_cplsize(asel, 0, NULL);
    cpl_array_delete(asel);
    cpl_table_set_double(gt->table, MUSE_GEOTABLE_ANGLE, irow, a);
    cpl_table_set_double(gt->table, MUSE_GEOTABLE_ANGLE"err", irow, ae);
    cpl_table_set_double(gt->table, MUSE_GEOTABLE_WIDTH, irow, w);
    cpl_table_set_double(gt->table, MUSE_GEOTABLE_WIDTH"err", irow, we);
    cpl_table_set_double(gt->table, "xrel", irow, xr);
    cpl_table_set_double(gt->table, "xrelerr", irow, xre);
    cpl_table_set_double(gt->table, "vpos", irow, p);
    cpl_table_set_double(gt->table, "vposerr", irow, pe);
    cpl_table_set_double(gt->table, "dxl", irow, dxl);
    cpl_table_set_double(gt->table, "dxr", irow, dxr);
    /* invalidate some columns that do not make sense any more, do not erase   *
     * them yet, so that other tables of the same basic format can be appended */
    cpl_table_set_invalid(gt->table, "flux", irow);
    cpl_table_set_invalid(gt->table, "lambda", irow);
    cpl_table_set_invalid(gt->table, "xc", irow);
    cpl_table_set_invalid(gt->table, "yc", irow);
    cpl_table_unselect_row(gt->table, irow);
    cpl_table_erase_selected(gt->table);
    cpl_table_delete(slice);
  } /* for nslice (all slices) */
#if 0
  printf("intermediate result: weighted averages of angle, width, and xrel:\n");
  cpl_table_dump(gt->table, 0, 1000000, stdout);
  fflush(stdout);
#endif

  /* Now go through one slicer stack, and for each slice with a result find   *
   * the other three (horizontally adjacent) slices. Compute the gaps between *
   * the slices and add them to the total budget to derive the central        *
   * horizontal position of all four slices in this row of the slicer stack.  */
  /* XXX how does the angle (of the mask, each IFU, or *
   *     the individual slices) affect the result?     */
  const unsigned short nsoff = kMuseSlicesPerCCD / 4; /* slice offset */
  for (nslice = 1 + nsoff; nslice <= 2*nsoff; nslice++) {
    /* get the entries for this one slice by sky numbering */
    cpl_table_unselect_all(gt->table);
    cpl_table_or_selected_int(gt->table, "SliceSky", CPL_EQUAL_TO, nslice - nsoff);
    cpl_table_or_selected_int(gt->table, "SliceSky", CPL_EQUAL_TO, nslice);
    cpl_table_or_selected_int(gt->table, "SliceSky", CPL_EQUAL_TO, nslice + nsoff);
    cpl_table_or_selected_int(gt->table, "SliceSky", CPL_EQUAL_TO, nslice + 2*nsoff);
    /* extract the selected rows so that we can access them easily */
    cpl_table *ts = cpl_table_extract_selected(gt->table);
    /* get the row indices for all four slicer stacks (if possible) */
    int irow, nrowts = cpl_table_get_nrow(ts),
        i1 = -1, i2 = -1, i3 = -1, i4 = -1;
    for (irow = 0; irow < nrowts; irow++) {
      /* here, we index the stacks again in the same way as the     *
       * optical numbering, from right to left in the field of view */
      switch (cpl_table_get_int(ts, "stack", irow, NULL)) {
      case 1: i1 = irow; break;
      case 2: i2 = irow; break;
      case 3: i3 = irow; break;
      case 4: i4 = irow; break;
      } /* switch */
    } /* for irow */
    /* at least the two central slicer stacks are required */
    if (i3 < 0 || i2 < 0) {
      char *msg = cpl_sprintf("For IFU %2hhu / row %2d in the slicer stacks "
                              "(slice sky numbers %02d, %02d, %02d, %02d), at "
                              "least one of the two middle stacks (%s/%s) is "
                              "missing", ifu, nslice - nsoff,
                              nslice - nsoff, nslice, nslice + nsoff, nslice + 2*nsoff,
                              i3 < 0 ? "left" : "-", i2 < 0 ? "right" : "-");
      cpl_error_set_message(__func__, CPL_ERROR_DATA_NOT_FOUND, "%s", msg);
      cpl_msg_error(__func__, "%s", msg);
      cpl_free(msg);
      cpl_table_dump(ts, 0, 10000, stdout);
      cpl_table_delete(ts);
      continue;
    }

    double *xrel = cpl_table_get_data_double(ts, "xrel"),
           *xrerr = cpl_table_get_data_double(ts, "xrelerr"),
           *width = cpl_table_get_data_double(ts, MUSE_GEOTABLE_WIDTH),
           *werr = cpl_table_get_data_double(ts, MUSE_GEOTABLE_WIDTH"err"),
           *pdxl = cpl_table_get_data_double(ts, "dxl"),
           *pdxr = cpl_table_get_data_double(ts, "dxr");
    /* also need the slice width in mm here, so create a new temporary column */
    cpl_table_duplicate_column(ts, "width_mm",
                               ts, MUSE_GEOTABLE_WIDTH);
    cpl_table_multiply_scalar(ts, "width_mm", 1. / kScaleX);
    cpl_table_set_column_unit(ts, "width_mm", "mm");
    cpl_table_duplicate_column(ts, "widtherr_mm",
                               ts, MUSE_GEOTABLE_WIDTH"err");
    cpl_table_multiply_scalar(ts, "widtherr_mm", 1. / kScaleX);
    cpl_table_set_column_unit(ts, "widtherr_mm", "mm");
    double *wmm = cpl_table_get_data_double(ts, "width_mm"),
           *werrmm = cpl_table_get_data_double(ts, "widtherr_mm");

    double cgap1 = (3. * kMuseCUmpmDX / fmaskrot
                    - (xrel[i3] + wmm[i3] / 2. + wmm[i2] / 2. - xrel[i2])) /* [mm] */
                 * kScaleX,
           cgerr = sqrt(pow(xrerr[i3], 2) + pow(xrerr[i2], 2) +
                        pow(werrmm[i3] / 2., 2) + pow(werrmm[i2] / 2., 2))
                 * kScaleX,
           cgap2 = kMuseCUmpmDX * (1. - pdxl[i3] - pdxr[i2]) /* [mm] */
                 * kScaleX,
           cgap = stdgap ? cgap1 : cgap2;
#if 0
      cpl_msg_debug(__func__, "cgap: %f, %f +/- %f", cgap1, cgap2, cgerr);
#endif
    /* for unlikely size, set error and output error message (the    *
     * error state might otherwise be swallowed by parallelization!) */
    if (cgap < 0 || cgap > 0.5) {
      cpl_msg_debug(__func__, "For IFU %2hhu / row %2d in the slicer stacks "
                    "(slice sky numbers %02d, %02d, %02d, %02d), the central "
                    "gap is unlikely (%f), reset to %.2f pix", ifu,
                    nslice - nsoff, nslice - nsoff, nslice, nslice + nsoff,
                    nslice + 2*nsoff, cgap, kMuseGeoMiddleGap);
      cgerr += sqrt(fabs(cgap)); /* add to the error estimate */
      cgap = kMuseGeoMiddleGap;
    }
    /* use this to compute effective centers of the two middle *
     * slices, distributing the gap equally to both sides      */
    cpl_table_set_double(ts, MUSE_GEOTABLE_X, i3, -(cgap / 2. + width[i3] / 2.));
    cpl_table_set_double(ts, MUSE_GEOTABLE_X, i2, cgap / 2. + width[i2] / 2.);
    cpl_table_set_double(ts, MUSE_GEOTABLE_X"err", i3,
                         sqrt(cgerr*cgerr + werr[i3]*werr[i3]) / 2.);
    cpl_table_set_double(ts, MUSE_GEOTABLE_X"err", i2,
                         sqrt(cgerr*cgerr + werr[i2]*werr[i2]) / 2.);

    double lgap = NAN, lgerr = NAN;
    if (i4 >= 0) {
      /* now compute the left gap, using the central *
       * gap and the widths of the two left slices   */
      lgerr = sqrt(pow(xrerr[i4], 2) + pow(xrerr[i3], 2) +
                   pow(werrmm[i4] / 2., 2) + pow(werrmm[i3] / 2., 2))
            * kScaleX;
      double lgap1 = (3. * kMuseCUmpmDX / fmaskrot
                      - (xrel[i4] + wmm[i4] / 2. + wmm[i3] / 2. - xrel[i3]))
                   * kScaleX,
             lgap2 = kMuseCUmpmDX * (1. - pdxl[i4] - pdxr[i3]) /* [mm] */
                 * kScaleX;
      lgap = stdgap ? lgap1 : lgap2;
#if 0
      cpl_msg_debug(__func__, "lgap: %f, %f +/- %f", lgap1, lgap2, lgerr);
#endif
      if (lgap < 0 || lgap > 0.5) {
        cpl_msg_debug(__func__, "For IFU %2hhu / row %2d in the slicer stacks"
                      " (slice sky numbers %02d, %02d, %02d, %02d), the left "
                      "gap is unlikely (%f), reset to %.2f pix", ifu,
                      nslice - nsoff, nslice - nsoff, nslice, nslice + nsoff,
                      nslice + 2*nsoff, lgap, kMuseGeoOuterGap);
        lgerr += sqrt(fabs(lgap)); /* add to the error estimate */
        lgap = kMuseGeoOuterGap;
      }
      cpl_table_set_double(ts, MUSE_GEOTABLE_X, i4,
                           -(cgap / 2. + width[i3] + lgap + width[i4] / 2.));
      cpl_table_set_double(ts, MUSE_GEOTABLE_X"err", i4,
                           sqrt(cgerr*cgerr / 4. + werr[i3]*werr[i3]
                                + lgerr*lgerr + werr[i4]*werr[i4] / 4.));
    } else {
      cpl_error_set_message(__func__, CPL_ERROR_DATA_NOT_FOUND, "For IFU %2hhu /"
                            " row %2d in the slicer stacks (slice sky numbers"
                            " %02d, %02d, %02d, %02d), the leftmost stack is "
                            "missing", ifu, nslice - nsoff,
                            nslice - nsoff, nslice, nslice + nsoff, nslice + 2*nsoff);
    }

    double rgap = NAN, rgerr = NAN;
    if (i1 >= 0) {
      /* and finally the right gap and the position of the rightmost slice */
      rgerr = sqrt(pow(xrerr[i2], 2) + pow(xrerr[i1], 2) +
                   pow(werrmm[i2] / 2., 2) + pow(werrmm[i1] / 2., 2))
            * kScaleX;
      double rgap1 = (3. * kMuseCUmpmDX / fmaskrot
                      - (xrel[i2] + wmm[i2] / 2. + wmm[i1] / 2. - xrel[i1]))
                   * kScaleX,
             rgap2 = kMuseCUmpmDX * (1. - pdxl[i2] - pdxr[i1]) /* [mm] */
                 * kScaleX;
      rgap = stdgap ? rgap1 : rgap2;
#if 0
      cpl_msg_debug(__func__, "rgap: %f, %f +/- %f", rgap1, rgap2, rgerr);
#endif
      if (rgap < 0 || rgap > 0.5) {
        cpl_msg_debug(__func__, "For IFU %2hhu / row %2d in the slicer stacks"
                      " (slice sky numbers %02d, %02d, %02d, %02d), the right"
                      " gap is unlikely (%f), reset to %.2f pix", ifu,
                      nslice - nsoff, nslice - nsoff, nslice, nslice + nsoff,
                      nslice + 2*nsoff, rgap, kMuseGeoOuterGap);
        rgerr += sqrt(fabs(rgap)); /* add to the error estimate */
        rgap = kMuseGeoOuterGap;
      }
      cpl_table_set_double(ts, MUSE_GEOTABLE_X, i1,
                           cgap / 2. + width[i2] + rgap + width[i1] / 2.);
      cpl_table_set_double(ts, MUSE_GEOTABLE_X"err", i1,
                           sqrt(cgerr*cgerr / 4. + werr[i2]*werr[i2]
                                + rgerr*rgerr + werr[i1]*werr[i1] / 4.));
    } else {
      cpl_error_set_message(__func__, CPL_ERROR_DATA_NOT_FOUND, "For IFU %2hhu /"
                            " row %2d in the slicer stacks (slice sky numbers"
                            " %02d, %02d, %02d, %02d), the rightmost stack is"
                            " missing", ifu, nslice - nsoff,
                            nslice - nsoff, nslice, nslice + nsoff, nslice + 2*nsoff);
    }
    cpl_msg_debug(__func__, "IFU %2hhu row %2d gaps (slice sky numbers %02d, "
                  "%02d, %02d, %02d): central %.3f +/- %.3f pix, left %.3f +/- "
                  "%.3f pix, right %.3f +/- %.3f pix", ifu, nslice - nsoff,
                  nslice - nsoff, nslice, nslice + nsoff, nslice + 2*nsoff,
                  cgap, cgerr, lgap, lgerr, rgap, rgerr);

    /* remove the temporary column again... */
    cpl_table_erase_column(ts, "width_mm");
    cpl_table_erase_column(ts, "widtherr_mm");

    /* erase the original entries in the gt->table, and replace them      *
     * with the ones we modified here, which contain the center positions */
    cpl_table_erase_selected(gt->table);
    cpl_table_insert(gt->table, ts, cpl_table_get_nrow(gt->table));
    cpl_table_delete(ts);
  } /* for nslice (all slices in the middle left stack) */

  /* sort gt->table again, by decreasing stack and increasing sky number */
  cpl_propertylist *order = cpl_propertylist_new();
  cpl_propertylist_append_bool(order, "stack", CPL_TRUE);
  cpl_propertylist_append_bool(order, "SliceSky", CPL_FALSE);
  cpl_table_sort(gt->table, order);
  cpl_propertylist_delete(order);
#if 0
  printf("intermediate result: only the y position is still missing:\n");
  cpl_table_dump(gt->table, 0, 1000000, stdout);
  fflush(stdout);
#endif
  return gt;
} /* muse_geo_determine_horizontal() */

/*----------------------------------------------------------------------------*/
/**
  @private
  @brief  Select possible reference IFU / slice combination for vertical
          centering.
  @param  aGeo     geometry table with all slices with data
  @param  aSlice   pointer to best slice reference number to return
  @return a the ifu number best suited as reference

  If IFU 12 is in the table, use IFU = 12, SliceSky = 24 the origin of the
  relative vertical position, otherwise use SliceSky 18 of the central IFU.

  @note This function does no error checking!
 */
/*----------------------------------------------------------------------------*/
static unsigned char
muse_geo_select_reference(const muse_geo_table *aGeo, unsigned short *aSlice)
{
  unsigned char ifu = 0;
  unsigned short slice = 0;
  /* copy table to not touch the original */
  cpl_table *geo = cpl_table_duplicate(aGeo->table);
  cpl_table_unselect_all(geo);
  cpl_table_or_selected_int(geo, MUSE_GEOTABLE_FIELD, CPL_EQUAL_TO, 12);
  cpl_table_and_selected_int(geo, MUSE_GEOTABLE_SKY, CPL_EQUAL_TO, 24);
  int nsel = cpl_table_count_selected(geo);
  if (nsel > 0) {
    ifu = 12;
    slice = 24;
  }
  unsigned char testifu = 13,
                testslice = 18;
  short testoffset = 1;
  while (ifu == 0 && slice == 0) {
    cpl_table_unselect_all(geo);
    cpl_table_or_selected_int(geo, MUSE_GEOTABLE_FIELD, CPL_EQUAL_TO, testifu);
    cpl_table_and_selected_int(geo, MUSE_GEOTABLE_SKY, CPL_EQUAL_TO, testslice);
    nsel = cpl_table_count_selected(geo);
    if (nsel > 0) {
      ifu = testifu;
      slice = testslice;
    }
    testifu += testoffset;
    if (testifu > kMuseNumIFUs) { /* reached the end, start again from the middle */
      testifu = 11;
      testoffset = -1;
    }
  } /* while */
  cpl_table_delete(geo);
  if (aSlice) {
    *aSlice = slice;
  }
  return ifu;
} /* muse_geo_select_reference() */

/*----------------------------------------------------------------------------*/
/**
  @brief  Refine relative horizontal positions of adjacent IFUs.
  @param  aGeo     partially filled geometry table containing pre-processed
                   results of the central spot of each slice
  @param  aSpots   spots table, merged dataset of measurements from all IFUs
  @return a cpl_table * containing the output geometry or NULL on error

  The input table aGeo is thought to be produced by appending multiple output
  tables from muse_geo_determine_horizontal() with different IFUs to each other.

  @note This function modifies aSpots by appending extra columns ("SliceCCD" and
        "stack") and changing the selection.

  Select the topmost and bottommost slices of each IFU of the central two
  slicer stacks in the aSpots table. Loop through all IFUs except the last and
  find the central spots at all wavelengths that appear in the adjacent slices
  at the same mask positions. Use their position relative to the center of the
  slice to compute the real offset in the focal plane between those adjacent
  slices. Collect the offsets in two arrays (one for each slice stack), reject
  outliers using histograms, and compute weighted mean offsets. These offsets
  are applied to all subsequent IFUs in the aGeo table, by subtracting this
  from the MUSE_GEOTABLE_XPOS column of the target IFU (and add it to the
  "xrel" column in mm); the corresponding error estimate is applied to the
  MUSE_GEOTABLE_XPOS"err" column.
  Finally, search the reference point (the middle between the middle of the row
  of the reference slices in the reference IFU), and shift the whole geometry
  table so that this point is at MUSE_GEOTABLE_XPOS = 0.

  If the environment variable MUSE_GEOMETRY_HORI_OFFSETS is set to a
  comma-delimited string of floats, then these floats are taken as extra pixel
  offsets used to shift subsequent IFUs, in addition to the automatic
  computation done here.

  Use muse_geo_determine_vertical() as the next step after this function to
  convert it to a fully usable GEOMETRY_TABLE.

  @error{return CPL_ERROR_NULL_INPUT, aGeo\, or aGeo->table\, and/or aSpots are NULL}
  @error{return CPL_ERROR_ILLEGAL_INPUT,
         aGeo contains less than 50 rows}
  @error{return CPL_ERROR_INCOMPATIBLE_INPUT,
         aGeo does not have all the columns as defined by muse_geo_table_def}
  @error{return CPL_ERROR_INCOMPATIBLE_INPUT,
         aSpots does not have all the columns as defined by muse_geo_measurements_def}
  @error{return CPL_ERROR_DATA_NOT_FOUND,
         no or only one IFU listed in the input aGeo table}
  @error{return CPL_ERROR_ILLEGAL_INPUT,
         aSpots contains a range of values of ScaleFOV}
  @error{continue with the next position/wavelength,
         not enough spots found at given position/wavelength that can be used for the comparison}
 */
/*----------------------------------------------------------------------------*/
cpl_error_code
muse_geo_refine_horizontal(muse_geo_table *aGeo, cpl_table *aSpots)
{
  cpl_ensure_code(aGeo && aGeo->table && aSpots, CPL_ERROR_NULL_INPUT);
  cpl_size nrow = cpl_table_get_nrow(aGeo->table);
  cpl_ensure_code(nrow >= 50, CPL_ERROR_ILLEGAL_INPUT);
  cpl_ensure_code(muse_cpltable_check(aGeo->table, muse_geo_table_def) == CPL_ERROR_NONE,
                  CPL_ERROR_INCOMPATIBLE_INPUT);
  cpl_ensure_code(muse_cpltable_check(aSpots, muse_geo_measurements_def) == CPL_ERROR_NONE,
                  CPL_ERROR_INCOMPATIBLE_INPUT);
  const unsigned char ifu1 = cpl_table_get_column_min(aGeo->table, MUSE_GEOTABLE_FIELD),
                      ifu2 = cpl_table_get_column_max(aGeo->table, MUSE_GEOTABLE_FIELD);
  if (!ifu1 || !ifu2 || ifu1 == ifu2) {
    return cpl_error_set_message(__func__, CPL_ERROR_DATA_NOT_FOUND,
                                 "input geometry table contains data of IFUs "
                                 "%2hhu .. %2hhu", ifu1, ifu2);
  }
  cpl_ensure_code(cpl_table_get_column_stdev(aSpots, "ScaleFOV") < 1e-10,
                  CPL_ERROR_ILLEGAL_INPUT);
  cpl_array *hoffsets = NULL;
  if (getenv("MUSE_GEOMETRY_HORI_OFFSETS")) {
    hoffsets = muse_cplarray_new_from_delimited_string(
                 getenv("MUSE_GEOMETRY_HORI_OFFSETS"), ",");
    cpl_msg_warning(__func__, "Overriding horizontal offsets, found %"
                    CPL_SIZE_FORMAT" values!", cpl_array_get_size(hoffsets));
  }
  const double kScaleX = kMuseTypicalCubeSizeX * aGeo->scale / 60.;

  /* create and fill the SliceSky and stack table columns in the spots table */
  cpl_table_new_column(aSpots, MUSE_GEOTABLE_SKY, CPL_TYPE_INT);
  cpl_table_new_column(aSpots, "stack", CPL_TYPE_INT);
  cpl_size ispot, nspots = cpl_table_get_nrow(aSpots);
  for (ispot = 0; ispot < nspots; ispot++) {
    int nslice = cpl_table_get_int(aSpots, "SliceCCD", ispot, NULL);
    cpl_table_set(aSpots, MUSE_GEOTABLE_SKY, ispot, kMuseGeoSliceSky[nslice - 1]);
    unsigned char stack = nslice <= 12 ? 4 : (nslice <= 24 ? 3 : (nslice <= 36 ? 2 : 1));
    cpl_table_set_int(aSpots, "stack", ispot, stack);
  } /* for ispot */

  /* extract top and bottom rows of the middle slicer stack *
   * to make the table smaller and better to handle         */
  const unsigned short nsoff = kMuseSlicesPerCCD / 4; /* slice offset */
  cpl_table_unselect_all(aSpots);
  cpl_table_or_selected_int(aSpots, MUSE_GEOTABLE_SKY, CPL_EQUAL_TO, 24 - nsoff + 1);
  cpl_table_or_selected_int(aSpots, MUSE_GEOTABLE_SKY, CPL_EQUAL_TO, 24);
  cpl_table_or_selected_int(aSpots, MUSE_GEOTABLE_SKY, CPL_EQUAL_TO, 24 + 1);
  cpl_table_or_selected_int(aSpots, MUSE_GEOTABLE_SKY, CPL_EQUAL_TO, 24 + nsoff);
#if 0
  cpl_msg_debug(__func__, "All top/bottom spots selected: %"
                CPL_SIZE_FORMAT, cpl_table_count_selected(aSpots));
#endif
  /* also apply a lower flux limit of 500. */
  cpl_table_and_selected_double(aSpots, "flux", CPL_NOT_LESS_THAN, 500.);
#if 0
  cpl_msg_debug(__func__, "All bright top/bottom spots selected: %"
                CPL_SIZE_FORMAT, cpl_table_count_selected(aSpots));
#endif
  /* only select the middle spot in each slice */
  cpl_table_and_selected_int(aSpots, "SpotNo", CPL_EQUAL_TO, 2);
#if 0
  cpl_msg_debug(__func__, "All bright and central top/bottom spots selected: %"
                CPL_SIZE_FORMAT, cpl_table_count_selected(aSpots));
#endif
  cpl_table *tbspots = cpl_table_extract_selected(aSpots); /* top/bottom spots */
  cpl_msg_debug(__func__, "All spots: %"CPL_SIZE_FORMAT", top/bottom spots to "
                "work with: %"CPL_SIZE_FORMAT, nspots, cpl_table_get_nrow(tbspots));
  nspots = cpl_table_get_nrow(tbspots);

  /* variables for the corrective loops */
  int *ifus = cpl_table_get_data_int(aGeo->table, MUSE_GEOTABLE_FIELD),
      *slices = cpl_table_get_data_int(aGeo->table, MUSE_GEOTABLE_SKY);
  double *xpos = cpl_table_get_data_double(aGeo->table, MUSE_GEOTABLE_X),
         *xerr = cpl_table_get_data_double(aGeo->table, MUSE_GEOTABLE_X"err"),
         *xrel = cpl_table_get_data_double(aGeo->table, "xrel");
  int irow;

  /* loop over the IFUs */
  unsigned char nifu;
  for (nifu = ifu1; nifu < ifu2; nifu++) { /* exclude the last IFU! */
    /* select and extract bottom slices of current IFU */
    cpl_table_unselect_all(tbspots);
    cpl_table_or_selected_int(tbspots, MUSE_GEOTABLE_SKY, CPL_EQUAL_TO, 24);
    cpl_table_or_selected_int(tbspots, MUSE_GEOTABLE_SKY, CPL_EQUAL_TO, 24 + nsoff);
    cpl_table_and_selected_int(tbspots, MUSE_GEOTABLE_FIELD, CPL_EQUAL_TO, nifu);
    cpl_table *xspots = cpl_table_extract_selected(tbspots);
    /* select and extract top slices of next IFU (below) */
    cpl_table_unselect_all(tbspots);
    cpl_table_or_selected_int(tbspots, MUSE_GEOTABLE_SKY, CPL_EQUAL_TO, 24 - nsoff + 1);
    cpl_table_or_selected_int(tbspots, MUSE_GEOTABLE_SKY, CPL_EQUAL_TO, 24 + 1);
    cpl_table_and_selected_int(tbspots, MUSE_GEOTABLE_FIELD, CPL_EQUAL_TO, nifu + 1);
    cpl_table *tmp = cpl_table_extract_selected(tbspots);
    cpl_table_insert(xspots, tmp, cpl_table_get_nrow(xspots));
    cpl_table_delete(tmp);
    int nxspots = cpl_table_get_nrow(xspots);

    /* sort the table, mainly to get ordered output for debugging */
    cpl_propertylist *order = cpl_propertylist_new();
    cpl_propertylist_append_bool(order, "lambda", CPL_FALSE);
    cpl_propertylist_append_bool(order, "VPOS", CPL_FALSE);
    cpl_propertylist_append_bool(order, "SliceSky", CPL_FALSE);
    cpl_table_sort(xspots, order);
    cpl_propertylist_delete(order);
#if 0
    printf("IFU %2hhu:\n", nifu);
    cpl_table_dump(xspots, 0, nxspots, stdout);
    fflush(stdout);
#endif

    /* create array for the dxcen-statistics and respective index */
    cpl_array *apos2 = cpl_array_new(nxspots, CPL_TYPE_DOUBLE),
              *apos3 = cpl_array_new(nxspots, CPL_TYPE_DOUBLE);
    int idx2 = 0, idx3 = 0;

    /* loop over all wavelengths, and positions to find common spots in adjacent IFUs */
    cpl_vector *vtmp = cpl_vector_wrap(nxspots,
                                       cpl_table_get_data_double(xspots, "lambda")),
               *lambdas = muse_cplvector_get_unique(vtmp);
    cpl_vector_unwrap(vtmp);
    vtmp = cpl_vector_wrap(nxspots, cpl_table_get_data_double(xspots, "VPOS"));
    cpl_vector *positions = muse_cplvector_get_unique(vtmp);
    cpl_vector_unwrap(vtmp);
    int ilambda, nlambda = cpl_vector_get_size(lambdas);
    for (ilambda = 0; ilambda < nlambda; ilambda++) {
      double lambda = cpl_vector_get(lambdas, ilambda);

      int ipos, npos = cpl_vector_get_size(positions);
      for (ipos = 0; ipos < npos; ipos++) {
        double vpos = cpl_vector_get(positions, ipos);

        int nstack; /* two middle stacks, from left to right in the FOV */
        for (nstack = 3; nstack >= 2; nstack--) {
          cpl_table_select_all(xspots);
          cpl_table_and_selected_double(xspots, "lambda", CPL_EQUAL_TO, lambda);
          cpl_table_and_selected_double(xspots, "VPOS", CPL_EQUAL_TO, vpos);
          cpl_table_and_selected_int(xspots, "stack", CPL_EQUAL_TO, nstack);
          int nsel = cpl_table_count_selected(xspots);
          if (nsel != 2) {
#if 0
            printf("IFU %2hhu, lambda = %f, VPOS = %f, stack = %d: %d selected rows!\n",
                   nifu, lambda, vpos, nstack, nsel);
#endif
            continue;
          }
          /* extract and sort the table of common positions */
          cpl_table *common = cpl_table_extract_selected(xspots);
          order = cpl_propertylist_new();
          cpl_propertylist_append_bool(order, "SubField", CPL_FALSE);
          cpl_propertylist_append_bool(order, "SliceSky", CPL_FALSE);
          cpl_table_sort(common, order);
          cpl_propertylist_delete(order);
          int nselthis = cpl_table_and_selected_int(common, "SubField",
                                                    CPL_EQUAL_TO, nifu);
          cpl_table_select_all(common);
          int nselnext = cpl_table_and_selected_int(common, "SubField",
                                                    CPL_EQUAL_TO, nifu + 1);
          if (nselthis != 1 || nselnext != 1) {
#if 0
            printf("\nIFU %2hhu, lambda = %f, VPOS = %f, stack = %d: "
                   "%d rows of IFU %2hhu, %d rows of IFU %2d!\n",
                   nifu, lambda, vpos, nstack, nselthis, nifu, nselnext,
                   (int)nifu + 1);
            cpl_table_dump(common, 0, 100000, stdout);
#endif
            cpl_table_delete(common);
            continue;
          }
          /* now, this IFU is in row 0, the next IFU is in row 1,  *
           * so we can finally compute the difference between both *
           * dxcen values, and store it in the array               */
          double xdiff1 = cpl_table_get(common, "dxcen", 0, NULL),
                 xdiff2 = cpl_table_get(common, "dxcen", 1, NULL),
                 twidth1 = cpl_table_get(common, "twidth", 0, NULL),
                 twidth2 = cpl_table_get(common, "twidth", 1, NULL);
          cpl_table_unselect_all(aGeo->table);
          cpl_table_or_selected_int(aGeo->table, MUSE_GEOTABLE_FIELD, CPL_EQUAL_TO,
                                    cpl_table_get_int(common, "SubField", 0, NULL));
          cpl_table_or_selected_int(aGeo->table, MUSE_GEOTABLE_SKY, CPL_EQUAL_TO,
                                    cpl_table_get_int(common, "SliceSky", 0, NULL));
          cpl_array *sel = cpl_table_where_selected(aGeo->table);
          double width1 = cpl_table_get_double(aGeo->table, MUSE_GEOTABLE_WIDTH,
                                               cpl_array_get(sel, 0, NULL), NULL);
          cpl_array_delete(sel);
          cpl_table_unselect_all(aGeo->table);
          cpl_table_or_selected_int(aGeo->table, MUSE_GEOTABLE_FIELD, CPL_EQUAL_TO,
                                    cpl_table_get_int(common, "SubField", 1, NULL));
          cpl_table_or_selected_int(aGeo->table, MUSE_GEOTABLE_SKY, CPL_EQUAL_TO,
                                    cpl_table_get_int(common, "SliceSky", 1, NULL));
          sel = cpl_table_where_selected(aGeo->table);
          double width2 = cpl_table_get_double(aGeo->table, MUSE_GEOTABLE_WIDTH,
                                               cpl_array_get(sel, 0, NULL), NULL);
          cpl_array_delete(sel);
#if 0
          printf("\nIFU %2hhu, lambda = %f, VPOS = %f, stack + %d, twidths: %f / %f, geowidths: %f / %f:\n",
                 nifu, lambda, vpos, nstack, twidth1, twidth2, width1, width2);
          cpl_table_dump(common, 0, 100000, stdout);
          printf("==> xdiff = %f pix, %f pix (corrected)\n", xdiff1 - xdiff2,
                 xdiff1 * width1 / twidth1 - xdiff2 * width2 / twidth2);
          fflush(stdout);
#endif
          double xdiff = xdiff1 * width1 / twidth1 - xdiff2 * width2 / twidth2;
          if (nstack == 3) {
            cpl_array_set(apos3, idx3++, xdiff);
          }
          if (nstack == 2) {
            cpl_array_set(apos2, idx2++, xdiff);
          }
          cpl_table_delete(common);
        } /* for nstack */
      } /* for ipos */
    } /* for ilambda */
    muse_cplarray_erase_invalid(apos2);
    muse_cplarray_erase_invalid(apos3);
#define HIST_BIN_WIDTH 0.1 /* 1/10 pix width of each histogram bin */
    cpl_bivector *hist2 = muse_cplarray_histogram(apos2, HIST_BIN_WIDTH, -5., 5.),
                 *hist3 = muse_cplarray_histogram(apos3, HIST_BIN_WIDTH, -5., 5.);
    /* use a gap of 2 histogram entries below 0.5 to define a gap */
    muse_cplarray_erase_outliers(apos2, hist2, 2, 0.5);
    muse_cplarray_erase_outliers(apos3, hist3, 2, 0.5);
    cpl_bivector_delete(hist2);
    cpl_bivector_delete(hist3);
    cpl_array *apos = cpl_array_new(0, cpl_array_get_type(apos2));
    cpl_array_insert(apos, apos2, 0);
    cpl_array_insert(apos, apos3, cpl_array_get_size(apos));
#if 0
    char *fn = cpl_sprintf("apos2_%02hhuto%02hhu.pos", nifu, nifu+1);
    FILE *fp = fopen(fn, "w");
    fprintf(fp, "# apos2 %2hhu to %2hhu:\n#", nifu, nifu+1);
    cpl_array_dump(apos2, 0, 1000000, fp);
    fclose(fp);
    cpl_free(fn);
    fn = cpl_sprintf("apos3_%02hhuto%02hhu.pos", nifu, nifu+1);
    fp = fopen(fn, "w");
    fprintf(fp, "# apos3 %2hhu to %2hhu:\n#", nifu, nifu+1);
    cpl_array_dump(apos3, 0, 1000000, fp);
    fclose(fp);
    cpl_free(fn);
    fn = cpl_sprintf("apos_%02hhuto%02hhu.pos", nifu, nifu+1);
    fp = fopen(fn, "w");
    fprintf(fp, "# apos %2hhu to %2hhu:\n#", nifu, nifu+1);
    cpl_array_dump(apos, 0, 1000000, fp);
    fclose(fp);
    cpl_free(fn);
#endif
    /* compute both average positions, their sigmas, and use that to create  *
     * a combined weighted average value with a corresponding error estimate */
    double mean2 = cpl_array_get_mean(apos2),
           stdev2 = cpl_array_get_stdev(apos2),
           var2 = stdev2 * stdev2,
           mean3 = cpl_array_get_mean(apos3),
           stdev3 = cpl_array_get_stdev(apos3),
           var3 = stdev3 * stdev3,
           mean = (mean2 + mean3) / 2.,
           wmean = (mean2 / var2 + mean3 / var3) / (1. / var2 + 1. / var3),
           wstdev = sqrt(1. / (1. / var2 + 1. / var3));
    cpl_array_delete(apos2);
    cpl_array_delete(apos3);
    cpl_msg_debug(__func__, "IFU %2hhu to IFU %2d: %6.3f +/- %5.3f pix "
                  "[stack 3: %6.3f +/- %5.3f, stack2: %6.3f +/- %5.3f ==> %6.3f"
                  " or %6.3f +/- %5.3f (%6.3f)]", nifu, (int)nifu + 1, wmean, wstdev,
                  mean3, stdev3, mean2, stdev2, mean,
                  cpl_array_get_mean(apos), cpl_array_get_stdev(apos),
                  cpl_array_get_median(apos));
    cpl_array_delete(apos);
    cpl_vector_delete(lambdas);
    cpl_vector_delete(positions);
    cpl_table_delete(xspots);

    /* now we have the offsets (and the error estimates), apply the shifts */
    for (irow = 0; irow < nrow; irow++) {
      if (ifus[irow] >= nifu + 1) {
        xpos[irow] -= wmean;
        xerr[irow] = sqrt(xerr[irow]*xerr[irow] + wstdev*wstdev);
        xrel[irow] += wmean / kScaleX;
      } /* if */
    } /* for irow (all table rows) */
  } /* for nifu */
  cpl_table_delete(tbspots);

  /* continue to see if there are manual adjustments to add */
  for (nifu = ifu1; nifu < ifu2; nifu++) { /* exclude the last IFU! */
    if (hoffsets) {
      double xdiff = 0.;
      if (cpl_array_get_size(hoffsets) >= nifu) { /* at least this many elements */
        const char *sdiff = cpl_array_get_string(hoffsets, nifu - 1);
        if (sdiff) {
          xdiff = atof(sdiff);
        } /* if override valid */
      } /* if override present from the next IFU */
      cpl_msg_debug(__func__, "Subtracting extra %7.4f pix from IFU %d onwards",
                    xdiff, (int)nifu + 1);
      for (irow = 0; irow < nrow; irow++) {
        if (ifus[irow] >= nifu + 1) {
          xpos[irow] -= xdiff;
          /* don't care about xrel in this case */
        } /* if */
      } /* for irow (all table rows) */
      continue; /* go to next IFU */
    } /* if hoffsets is there (override active) */
#if 0
    printf("%s after correcting %d:\n", __func__, (int)nifu + 1);
    cpl_table_dump(aGeo->table, 0, 1000000, stdout);
    fflush(stdout);
#endif
  } /* for nifu */
  cpl_array_delete(hoffsets);

  /* find the horizontal center of the reference slice and shift the whole *
   * table so that the center is between the ref. slice and its neighbor   */
  double xref = 0.;
  unsigned short sliceref;
  const unsigned char ifuref = muse_geo_select_reference(aGeo, &sliceref);
  for (irow = 0; irow < nrow; irow++) {
    if (ifus[irow] == ifuref &&
        (slices[irow] == sliceref || slices[irow] == sliceref + 12)) {
      xref += xpos[irow];
    } /* if */
  } /* for irow (all table rows) */
  xref /= 2.; /* current mean center */
  cpl_msg_debug(__func__, "Reference point (IFU %2hhu, SliceSky %2hu/%2d) "
                "currently centered at %f pix, correcting this offset", ifuref,
                sliceref, (int)sliceref + 12, xref);
  cpl_table_subtract_scalar(aGeo->table, MUSE_GEOTABLE_X, xref);

  return CPL_ERROR_NONE;
} /* muse_geo_refine_horizontal() */

/*----------------------------------------------------------------------------*/
/**
  @brief  Use all properties of the central spot and the horizontal properties
          in each slice to compute the vertical position.
  @param  aGeo   partially filled geometry table containing pre-processed
                 results of the central spot of each slice
  @return a cpl_table * containing the output geometry or NULL on error

  The input table aGeo is thought to be produced by appending multiple output
  tables from muse_geo_determine_horizontal() -- or rather
  muse_geo_refine_horizontal() -- with different IFUs to each other.

  Duplicate the input table into the result table. In a temporary column,
  convert the "vpos" values to offsets relative to a reference slice. Then
  loop through all slicer stacks and over all slices within each stack to find
  the y-position relative to the reference slice. If the expected vertical
  distance between two slices is off by more than a factor of 2 in one
  direction or a factor of 5 in the other, then the slice was illuminated by a
  different pinhole. Subtracting the pinhole distance (kMuseCUmpmDY) from the
  relative offset then gives the proper vertical position. If one of more IFUs
  are missing, a gap of kMuseGeoIFUVGap is added for each missing IFU, and a
  warning is printed.
  The error estimated in MUSE_GEOTABLE_Y"err" is copied from "vposerr".

  The above gives good results for all slices, except for those on the edge
  towards the next IFU. These might partly lie in the shadow of that adjacent
  IFU. To correct for this, this function computes the standard vertical
  distance between all non-edge slices and uses that to correct the offset of
  the top and bottom slices. Such an offset is only computed and applied, if
  the distance between the edge slices and the standard distance is
  significant.

  Finally, MUSE_GEOTABLE_Y and MUSE_GEOTABLE_Y"err" are converted to pix units.

  The output table only contains the mandatory columns of muse_geo_table_def and
  their error estimates, but is not sorted and only contains entries for slices
  with a valid result. Use muse_geo_finalize() to convert it to a fully usable
  GEOMETRY_TABLE.

  @qa An INM-generated field will be used to check that the geometry
      computed by this routine is accurate.

  @error{set CPL_ERROR_NULL_INPUT\, return NULL, aGeo or aGeo->table are NULL}
  @error{set CPL_ERROR_ILLEGAL_INPUT\, return NULL,
         aGeo->table contains less than 10 rows}
  @error{set CPL_ERROR_INCOMPATIBLE_INPUT\, return NULL,
         aGeo->table does not have all the columns as defined by muse_geo_table_def}
  @error{set CPL_ERROR_ILLEGAL_INPUT\, return NULL,
         more than one spot is still present in the input table}
 */
/*----------------------------------------------------------------------------*/
muse_geo_table *
muse_geo_determine_vertical(const muse_geo_table *aGeo)
{
  cpl_ensure(aGeo && aGeo->table, CPL_ERROR_NULL_INPUT, NULL);
  int nrow = cpl_table_get_nrow(aGeo->table);
  cpl_ensure(nrow >= 10, CPL_ERROR_ILLEGAL_INPUT, NULL);
  cpl_ensure(muse_cpltable_check(aGeo->table, muse_geo_table_def) == CPL_ERROR_NONE,
             CPL_ERROR_INCOMPATIBLE_INPUT, NULL);
  int spotmin = cpl_table_get_column_min(aGeo->table, "spot"),
      spotmax = cpl_table_get_column_max(aGeo->table, "spot");
  cpl_ensure(spotmin == spotmax, CPL_ERROR_ILLEGAL_INPUT, NULL);
  const double kScaleY = 60. / aGeo->scale / kMuseTypicalCubeSizeY;

  /* duplicate the input table, but already remove some useless lines */
  muse_geo_table *gt = muse_geo_table_duplicate(aGeo);
  cpl_table_erase_column(gt->table, "dxerr");
  cpl_table_erase_column(gt->table, "dxl");
  cpl_table_erase_column(gt->table, "dxr");
  cpl_table_erase_column(gt->table, "xc");
  cpl_table_erase_column(gt->table, "yc");
  cpl_table_erase_column(gt->table, "dx");
  cpl_table_erase_column(gt->table, "flux");
  cpl_table_erase_column(gt->table, "lambda");

  /* set the vertical position value */
  // XXX this needs to take into account the angle and the relative shift of the middle spot
  cpl_table_fill_column_window_double(gt->table, MUSE_GEOTABLE_Y, 0, nrow, 0.);
  cpl_table_add_columns(gt->table, MUSE_GEOTABLE_Y, "vpos");
  cpl_table_set_column_unit(gt->table, MUSE_GEOTABLE_Y, "mm");
  cpl_table_fill_column_window_double(gt->table, MUSE_GEOTABLE_Y"err", 0, nrow, 0.);
  cpl_table_add_columns(gt->table, MUSE_GEOTABLE_Y"err", "vposerr");
  cpl_table_set_column_unit(gt->table, MUSE_GEOTABLE_Y"err", "mm");
#if 0
  printf("\nfull geometry table, with absolute \"y\" [mm]:\n");
  cpl_table_dump(gt->table, 0, nrow, stdout);
  fflush(stdout);
#endif

  double maskangle = 0., fmaskrot = 1.;
  if (getenv("MUSE_GEOMETRY_MASK_ROTATION")) {
    maskangle = atof(getenv("MUSE_GEOMETRY_MASK_ROTATION"));
    fmaskrot = cos(maskangle * CPL_MATH_RAD_DEG);
    cpl_msg_warning(__func__, "Adapting to global mask rotation of %.4f deg "
                    "(cos = %.4e)", maskangle, fmaskrot);
  }
  double pinholedy = kMuseCUmpmDY;
  if (getenv("MUSE_GEOMETRY_PINHOLE_DY")) {
    pinholedy = atof(getenv("MUSE_GEOMETRY_PINHOLE_DY"));
    cpl_msg_info(__func__, "Using pinhole y distance of %f mm (instead of "
                 "%f mm)", pinholedy, kMuseCUmpmDY);
  }
  cpl_boolean printgoing = getenv("MUSE_DEBUG_GEO_VERTICAL")
                         && atoi(getenv("MUSE_DEBUG_GEO_VERTICAL")) > 0;

  unsigned short middleSlice;
  const unsigned char middleIFU = muse_geo_select_reference(aGeo, &middleSlice);
  cpl_msg_info(__func__, "Using IFU %2hhu / SliceSky %2hu as reference",
               middleIFU, middleSlice);
  double ycentral = NAN,
         ymax = -DBL_MAX, ymin = DBL_MAX;
  int irow;
  for (irow = 0; irow < nrow; irow++) {
    unsigned char ifu = cpl_table_get_int(gt->table, MUSE_GEOTABLE_FIELD, irow, NULL);
    unsigned short slice = cpl_table_get_int(gt->table, MUSE_GEOTABLE_SKY, irow, NULL);
    double y = cpl_table_get_double(gt->table, MUSE_GEOTABLE_Y, irow, NULL);
    if (ifu == middleIFU && slice == middleSlice) {
      ycentral = y;
      break;
    }
    if (y > ymax) {
      ymax = y;
    }
    if (y < ymin) {
      ymin = y;
    }
  } /* for irow (all table rows) */
  if (!isfinite(ycentral)) { /* average min and max */
    ycentral = (ymin + ymax) / 2.;
    cpl_msg_info(__func__, "Averaged the y range to ycentral = %f pix", ycentral);
  } else {
    cpl_msg_info(__func__, "Found IFU %2hhu, slice %2hu, using its y value as "
                 "ycentral = %f pix", middleIFU, middleSlice, ycentral);
  }
  cpl_table_subtract_scalar(gt->table, MUSE_GEOTABLE_Y, ycentral);
  const unsigned short nsoff = kMuseSlicesPerCCD / 4, /* slice offset */
                       nstack[] = { 3, 2, 4, 1, 0 };
  unsigned char i;
  for (i = 0; nstack[i] > 0; i++) {
    /* unselect middle-left slicer stack and delete the rest */
    cpl_table_unselect_all(gt->table);
    cpl_table_or_selected_int(gt->table, "stack", CPL_EQUAL_TO, nstack[i]);
    cpl_table *tstack = cpl_table_extract_selected(gt->table);
    int ntsrow = cpl_table_get_nrow(tstack);
    /* sort the stack table by subfield and then slice number on sky */
    cpl_propertylist *sorting = cpl_propertylist_new();
    cpl_propertylist_append_bool(sorting, MUSE_GEOTABLE_FIELD, CPL_FALSE);
    cpl_propertylist_append_bool(sorting, MUSE_GEOTABLE_SKY, CPL_FALSE);
    cpl_table_sort(tstack, sorting);
    cpl_propertylist_delete(sorting);
    /* find the slice in the same slicer row as the reference slice from above */
    const unsigned short refslice = middleSlice - (nstack[i] - 3) * nsoff;
    cpl_table_select_all(tstack);
    cpl_table_and_selected_int(tstack, MUSE_GEOTABLE_FIELD, CPL_EQUAL_TO, middleIFU);
    cpl_table_and_selected_int(tstack, MUSE_GEOTABLE_SKY, CPL_EQUAL_TO, refslice);
    if (cpl_table_count_selected(tstack) <= 0) {
      char *msg = cpl_sprintf("reference slice %2hu of reference IFU %2hhu not "
                              "found in slicer stack %hu!", refslice,
                              middleIFU, nstack[i]);
      cpl_msg_error(__func__, "%s", msg);
      cpl_error_set_message(__func__, CPL_ERROR_DATA_NOT_FOUND, "%s", msg);
      cpl_free(msg);
      cpl_table_delete(tstack);
      continue;
    }
    cpl_table_erase_selected(gt->table);
    cpl_array *asel = cpl_table_where_selected(tstack);
    int iref = cpl_array_get(asel, 0, NULL);
    cpl_array_delete(asel);
    double yref = cpl_table_get_double(tstack, MUSE_GEOTABLE_Y, iref, NULL);
    cpl_msg_info(__func__, "reference slice %2hu of reference IFU %2hhu found at row"
                 " %d in slicer stack %hu!", refslice, middleIFU, iref, nstack[i]);
    /* Maybe the reference pinhole in this slicer stack was illuminated by a  *
     * different pinhole than the one in the reference stack. Then we need to *
     * subtract one pinhole distance from the y-positions in this stack.      */
    if (fabs(yref) > pinholedy / 2.) {
      cpl_msg_info(__func__, "%s vertical pinhole distance (%f) to "
                   "recenter stack %hu", yref < 0. ? "adding" : "subtracting",
                   pinholedy, nstack[i]);
      cpl_table_add_scalar(tstack, MUSE_GEOTABLE_Y, yref < 0. ? pinholedy : -pinholedy);
      yref = cpl_table_get_double(tstack, MUSE_GEOTABLE_Y, iref, NULL);
    }
    /* go back in the table from the reference slice, the input y values *
     * should decrease, the output y value are supposed to increase      */
    cpl_table_new_column(tstack, "dy", CPL_TYPE_DOUBLE);
    cpl_table_set_column_unit(tstack, "dy", "mm");
    cpl_table_set_column_format(tstack, "dy", "%9.5f");
    cpl_table_set_double(tstack, "dy", iref, yref);
    /* keep the current y-position in a temporary column, "ycopy" */
    cpl_table_duplicate_column(tstack, "ycopy", tstack, MUSE_GEOTABLE_Y);
    cpl_table_set_double(tstack, MUSE_GEOTABLE_Y, iref, yref);
    double yprev = cpl_table_get_double(tstack, "ycopy", iref, NULL),
           dyoffset = pinholedy * fmaskrot,
           ysum = yref;
    int iprev;
    for (irow = iref - 1, iprev = iref; irow >= 0; iprev = irow, irow--) {
      /* see how many slices apart we are from the previous */
      int difu = cpl_table_get_int(tstack, MUSE_GEOTABLE_FIELD, iprev, NULL)
               - cpl_table_get_int(tstack, MUSE_GEOTABLE_FIELD, irow, NULL),
          dslice = cpl_table_get_int(tstack, MUSE_GEOTABLE_SKY, iprev, NULL)
                 - cpl_table_get_int(tstack, MUSE_GEOTABLE_SKY, irow, NULL);
      if (difu != 0) {
        dslice += nsoff;
      }
      double y = cpl_table_get_double(tstack, "ycopy", irow, NULL),
             dy = y - yprev, /* should be negative here, around -120 um */
             dexpect = dslice * kScaleY,
             dratio = dy / dexpect,
             dycor = 0.; /* correction to y distance, zero by default */
      if (dratio < -8.) { /* negative ratio here */
        dycor = 2 * dyoffset;
      } else if (dratio < -2.) {
        dycor = dyoffset;
      } else if (dratio > 5.) {
        dycor = -dyoffset;
      }
      if (printgoing) {
        cpl_msg_debug(__func__, "going back: %d %d, %f %f --> diff %9.6f "
                      "expected %f ratio %9.6f --> dy = %f", difu, dslice,
                      yprev, y, dy, dexpect, dratio, dy + dycor);
      }
      dy += dycor;
      if (abs(difu) > 1) { /* make a gap, of somewhat arbitrary size */
        double gap = abs(difu) * kMuseGeoIFUVGap;
        dy -= gap;
        cpl_msg_warning(__func__, "%d missing IFUs, guessing distance as %f",
                        abs(difu) - 1, gap);
      }
      cpl_table_set_double(tstack, "dy", irow, dy);
      ysum += dy;
      cpl_table_set_double(tstack, MUSE_GEOTABLE_Y, irow, ysum);
      yprev = y;
    } /* for irow (lower half of the stack table) */
    /* go forward in the table from the reference slice, the input y values *
     * should increase, the output y value are supposed to decrease         */
    yprev = cpl_table_get_double(tstack, "ycopy", iref, NULL);
    ysum = yref; /* start summing with the first y step from the ref. slice */
    for (irow = iref + 1, iprev = iref; irow < ntsrow; iprev = irow, irow++) {
      int difu = cpl_table_get_int(tstack, MUSE_GEOTABLE_FIELD, iprev, NULL)
               - cpl_table_get_int(tstack, MUSE_GEOTABLE_FIELD, irow, NULL),
          dslice = cpl_table_get_int(tstack, MUSE_GEOTABLE_SKY, iprev, NULL)
                 - cpl_table_get_int(tstack, MUSE_GEOTABLE_SKY, irow, NULL);
      if (difu != 0) {
        dslice -= nsoff;
      }
      double y = cpl_table_get_double(tstack, "ycopy", irow, NULL),
             dy = y - yprev, /* should be positive here, around +120 um */
             dexpect = -dslice * kScaleY,
             dratio = dy / dexpect,
             dycor = 0.; /* correction to y distance, zero by default */
      if (dratio > 8.) { /* positive ratio here */
        dycor = -2 * dyoffset;
      } else if (dratio > 2.) {
        dycor = -dyoffset;
      } else if (dratio < -5.) {
        dycor = dyoffset;
      }
      if (printgoing) {
        cpl_msg_debug(__func__, "going forward: %d %d, %f %f --> diff %9.6f "
                      "expected %f ratio %9.6f --> dy = %f", difu, dslice,
                      yprev, y, dy, dexpect, dratio, dy + dycor);
      }
      dy += dycor;
      if (abs(difu) > 1) { /* make a gap, of somewhat arbitrary size */
        double gap = abs(difu) * kMuseGeoIFUVGap;
        dy += gap;
        cpl_msg_warning(__func__, "%d missing IFUs, guessing distance as %f",
                        abs(difu) - 1, gap);
      }
      cpl_table_set_double(tstack, "dy", irow, dy);
      ysum += dy;
      cpl_table_set_double(tstack, MUSE_GEOTABLE_Y, irow, ysum);
      yprev = y;
    } /* for irow (upper half of the stack table) */
    if (printgoing) {
      printf("\ngeometry table of slicer stack %hu, with \"dy\" [mm]:\n",
             nstack[i]);
      cpl_table_dump(tstack, 0, nrow, stdout);
      fflush(stdout);
    }
    /* done with the temporary columns "ycopy" and "dy" */
    cpl_table_erase_column(tstack, "ycopy");
    cpl_table_erase_column(tstack, "dy");
    /* insert this slicer stack data back into the main table */
    cpl_table_insert(gt->table, tstack, cpl_table_get_nrow(gt->table));
    cpl_table_delete(tstack);
  } /* for i (all slicer stacks in a particular order) */

  /* The above should have correctly derived the vertical central positions *
   * of the slices as seens from the data on the CCD. This does not work    *
   * correctly for the slices at the top and/or bottom of each IFU, since   *
   * they may lie within the shadow of the adjacent IFU.                    *
   * To correct this, go though all stacks in all IFUs separately, and try  *
   * to reset the vertical distance between the two outermost slices to the *
   * average distance.                                                      */
  cpl_msg_info(__func__, "Correcting vertical position of top/bottom slices");
  unsigned char ifu;
  for (ifu = 1; ifu <= kMuseNumIFUs; ifu++) {
    for (i = 0; nstack[i] > 0; i++) {
      cpl_table_unselect_all(gt->table);
      cpl_table_or_selected_int(gt->table, "SubField", CPL_EQUAL_TO, ifu);
      cpl_table_and_selected_int(gt->table, "stack", CPL_EQUAL_TO, nstack[i]);
      if (!cpl_table_count_selected(gt->table)) {
        continue; /* next stack or IFU */
      }
      cpl_table *tstack = cpl_table_extract_selected(gt->table);
      int ntsrow = cpl_table_get_nrow(tstack);
      /* sort the stack table by sky slice number */
      cpl_propertylist *sorting = cpl_propertylist_new();
      cpl_propertylist_append_bool(sorting, MUSE_GEOTABLE_SKY, CPL_FALSE);
      cpl_table_sort(tstack, sorting);
      cpl_propertylist_delete(sorting);

      /* first and last entry should now be the edge slices, but verify this */
      unsigned short nslicetop = cpl_table_get_int(tstack, MUSE_GEOTABLE_SKY, 0, NULL),
                     nslicebot = cpl_table_get_int(tstack, MUSE_GEOTABLE_SKY,
                                                   ntsrow - 1, NULL);
      cpl_boolean hastop = nslicetop % nsoff == 1,
                  hasbot = nslicebot % nsoff == 0,
                  haschanged = CPL_FALSE; /* if we changed the value(s) */
#if 0
      printf("table of IFU %2hhu / SliceSky %2hu: %hu to %hu (%s, %s)\n",
             ifu, nstack[i], nslicetop, nslicebot,
             hastop ? "has top" : "does NOT have top",
             hasbot ? "has bottom" : "does NOT have bottom");
      cpl_table_dump(tstack, 0, 1000, stdout);
      fflush(stdout);
#endif

      cpl_vector *vvpos = cpl_vector_new(ntsrow - 3),
                 *vverr = cpl_vector_new(ntsrow - 3);
      for (irow = 1; irow < ntsrow - 2; irow++) {
        double dy = fabs(cpl_table_get_double(tstack, MUSE_GEOTABLE_Y, irow + 1, NULL)
                         - cpl_table_get_double(tstack, MUSE_GEOTABLE_Y, irow, NULL)),
               dye1 = cpl_table_get_double(tstack, MUSE_GEOTABLE_Y"err", irow, NULL),
               dye2 = cpl_table_get_double(tstack, MUSE_GEOTABLE_Y"err", irow + 1, NULL),
               dyerr = sqrt(dye1*dye1 + dye2*dye2);
        cpl_vector_set(vvpos, irow - 1, dy);
        cpl_vector_set(vverr, irow - 1, dyerr);
      } /* for irow (slices 2 .. 11 in this stack) */
      /* XXX should use a weighted mean but for now use the *
       *     standard means of value and error vectors      */
      double mean = cpl_vector_get_mean(vvpos),
             median = cpl_vector_get_median_const(vvpos),
             stdev = cpl_vector_get_stdev(vvpos),
             stdev2 = cpl_vector_get_mean(vverr);
      cpl_vector_delete(vvpos);
      cpl_vector_delete(vverr);
      cpl_msg_debug(__func__, "dy of IFU %2hhu / SliceSky %2hu: %f +/- %f (%f) [+/- %f]",
                    ifu, nstack[i], mean, stdev, median, stdev2);
      if (hastop) {
        /* y-distance at top */
        double ytop = cpl_table_get_double(tstack, MUSE_GEOTABLE_Y, 0, NULL),
               dyt = fabs(ytop - cpl_table_get_double(tstack, MUSE_GEOTABLE_Y, 1, NULL)),
               dyt1 = cpl_table_get_double(tstack, MUSE_GEOTABLE_Y"err", 0, NULL),
               dyt2 = cpl_table_get_double(tstack, MUSE_GEOTABLE_Y"err", 1, NULL),
               dyterr = sqrt(dyt1*dyt1 + dyt2*dyt2);
        cpl_msg_debug(__func__, "dy of IFU %2hhu / SliceSky %2hu (top):    %f +/- %f",
                      ifu, nstack[i], dyt, dyterr);
        /* if the difference in distance is larger than the combined *
         * 1-sigma errors, then apply the difference as correction   */
        if (mean - dyt > sqrt(dyterr*dyterr + stdev2*stdev2)) {
          /* add the difference to the y-position of the first (=top) slice */
          ytop += mean - dyt;
          cpl_table_set_double(tstack, MUSE_GEOTABLE_Y, 0, ytop);
          haschanged = CPL_TRUE;
#if 0
          printf("new top entry (+%f):\n", mean - dyt);
          cpl_table_dump(tstack, 0, 1, stdout);
          fflush(stdout);
#endif
        } /* if diff greater than error */
      } /* if hastop */
      if (hasbot) {
        /* y-distance (and error) at bottom */
        double ybot = cpl_table_get_double(tstack, MUSE_GEOTABLE_Y, ntsrow - 1, NULL),
               dyb = fabs(ybot - cpl_table_get_double(tstack, MUSE_GEOTABLE_Y, ntsrow - 2, NULL)),
               dyb1 = cpl_table_get_double(tstack, MUSE_GEOTABLE_Y"err", ntsrow - 2, NULL),
               dyb2 = cpl_table_get_double(tstack, MUSE_GEOTABLE_Y"err", ntsrow - 1, NULL),
               dyberr = sqrt(dyb1*dyb1 + dyb2*dyb2);
        cpl_msg_debug(__func__, "dy of IFU %2hhu / SliceSky %2hu (bottom): %f +/- %f",
                      ifu, nstack[i], dyb, dyberr);
        if (mean - dyb > sqrt(dyberr*dyberr + stdev2*stdev2)) {
          /* subtract the difference from the y-position of the last (=bottom) slice */
          ybot -= mean - dyb;
          cpl_table_set_double(tstack, MUSE_GEOTABLE_Y, ntsrow - 1, ybot);
          haschanged = CPL_TRUE;
#if 0
          printf("new bottom entry (-%f):\n", mean - dyb);
          cpl_table_dump(tstack, ntsrow - 1, 1, stdout);
          fflush(stdout);
#endif
        } /* if diff greater than error */
      } /* if hasbot */

      /* if we changed something, append the changed  *
       * table portion back to the main gt->table */
      if (haschanged) {
        cpl_table_erase_selected(gt->table);
        cpl_table_insert(gt->table, tstack, cpl_table_get_nrow(gt->table));
      }
      cpl_table_delete(tstack);
    } /* for i (slicer stacks) */
  } /* for ifu */

  /* convert "y" column from [mm] to [pix] */
  cpl_table_divide_scalar(gt->table, MUSE_GEOTABLE_Y, kMuseSpaxelSizeY_WFM / aGeo->scale);
  cpl_table_set_column_unit(gt->table, MUSE_GEOTABLE_Y, "pix");
  cpl_table_divide_scalar(gt->table, MUSE_GEOTABLE_Y"err", kMuseSpaxelSizeY_WFM / aGeo->scale);
  cpl_table_set_column_unit(gt->table, MUSE_GEOTABLE_Y"err", "pix");

  /* now we can delete the remaining useless lines in the output table */
  cpl_table_erase_column(gt->table, "spot");
  cpl_table_erase_column(gt->table, "xrel");
  cpl_table_erase_column(gt->table, "xrelerr");
  cpl_table_erase_column(gt->table, "vpos");
  cpl_table_erase_column(gt->table, "vposerr");
  cpl_table_erase_column(gt->table, "stack");
  return gt;
} /* muse_geo_determine_vertical() */

/*----------------------------------------------------------------------------*/
/**
  @brief  Create a final version of a geometry table.
  @param  aGeo   table containing all propoerties that could be measured
  @return CPL_ERROR_NONE on success, another CPL error code on failure

  The input table aGeo is thought to be produced by
  muse_geo_determine_vertical(), but does not require the columns with error
  estimates.

  This function first checks that all mandatory table columns are present
  (MUSE_GEOTABLE_FIELD, MUSE_GEOTABLE_CCD, MUSE_GEOTABLE_SKY, MUSE_GEOTABLE_X,
  MUSE_GEOTABLE_Y, MUSE_GEOTABLE_ANGLE, and MUSE_GEOTABLE_WIDTH).

  If it detects MUSE_GEOMETRY_PINHOLE_DY set in the environment, it then
  modifies the input geometry table by adapting the vertical scaling to the
  real vertical mask pinhole distance (kMuseCUmpmDY), changing the entries in
  the columns MUSE_GEOTABLE_Y and MUSE_GEOTABLE_ANGLE accordingly.

  It then adds missing slices of IFUs that are present: entries for such slices
  are filled with NANs for x and y position, a zero width, and a zero angle.
  IFUs for which no slices are present are not filled in.

  The table is finally flipped (unless the environment variable
  MUSE_GEOMETRY_NO_INVERSION is set to a positive integer) and sorted by
  increasing IFU number and increasing sky slice number.

  This finally puts the table into the format format of the GEOMETRY_TABLE as
  defined in the DRLDesign document, Sect. 5.1.

  @error{return CPL_ERROR_NULL_INPUT, aGeo or aGeo->table are NULL}
  @error{return CPL_ERROR_ILLEGAL_INPUT,
         aGeo is missing one of the mandatory columns}
 */
/*----------------------------------------------------------------------------*/
cpl_error_code
muse_geo_finalize(muse_geo_table *aGeo)
{
  cpl_ensure_code(aGeo && aGeo->table, CPL_ERROR_NULL_INPUT);
  cpl_ensure_code(cpl_table_has_column(aGeo->table, MUSE_GEOTABLE_FIELD) &&
                  cpl_table_has_column(aGeo->table, MUSE_GEOTABLE_CCD) &&
                  cpl_table_has_column(aGeo->table, MUSE_GEOTABLE_SKY) &&
                  cpl_table_has_column(aGeo->table, MUSE_GEOTABLE_X) &&
                  cpl_table_has_column(aGeo->table, MUSE_GEOTABLE_Y) &&
                  cpl_table_has_column(aGeo->table, MUSE_GEOTABLE_ANGLE) &&
                  cpl_table_has_column(aGeo->table, MUSE_GEOTABLE_WIDTH),
                  CPL_ERROR_ILLEGAL_INPUT);

  /* correct the computed values by the wrong pinhole vertical distance */
  if (getenv("MUSE_GEOMETRY_PINHOLE_DY")) {
#if 0
    FILE *fn1 = fopen("gt1.ascii", "w");
    fprintf(fn1, "geometry table before scaling\n");
    cpl_table_dump(aGeo->table, 0, 10000, fn1);
    fclose(fn1);
#endif
    double pinholedy = atof(getenv("MUSE_GEOMETRY_PINHOLE_DY")),
           fdy = kMuseCUmpmDY / pinholedy;
    cpl_msg_info(__func__, "Pinhole y distance of %f mm was used instead of "
                 "%f mm; scaling coordinates by %f!", pinholedy, kMuseCUmpmDY,
                 fdy);
    int irow, nrow = cpl_table_get_nrow(aGeo->table);
    for (irow = 0; irow < nrow; irow++) {
      int err;
      /* scale the y coordinate directly */
      double y = cpl_table_get_double(aGeo->table, MUSE_GEOTABLE_Y, irow, &err);
      if (!err) {
        cpl_table_set_double(aGeo->table, MUSE_GEOTABLE_Y, irow, y * fdy);
      }
      /* Compute a vertical scale for the old angle, and scale that *
       * to compute the new angle, so that I don't have to use the  *
       * approximate small-scale linearity of the tan() function.   */
      double angleold = cpl_table_get_double(aGeo->table, MUSE_GEOTABLE_ANGLE, irow, &err);
      if (!err) {
        double anglenew = atan(fdy * tan(angleold * CPL_MATH_RAD_DEG))
                        * CPL_MATH_DEG_RAD;
        cpl_table_set_double(aGeo->table, MUSE_GEOTABLE_ANGLE, irow, anglenew);
      }
    } /* for irow (all table rows) */
#if 0
    FILE *fn2 = fopen("gt2.ascii", "w");
    fprintf(fn2, "geometry table after scaling by %f\n", fdy);
    cpl_table_dump(aGeo->table, 0, 10000, fn2);
    fclose(fn2);
#endif
  } /* if */

  /* pad table with nonsense data for missing slices, but ignore missing IFUs */
  unsigned char nifu;
  for (nifu = 1; nifu <= kMuseNumIFUs; nifu++) {
    cpl_table_select_all(aGeo->table);
    cpl_table_and_selected_int(aGeo->table, MUSE_GEOTABLE_FIELD, CPL_EQUAL_TO, nifu);
    if (cpl_table_count_selected(aGeo->table) < 1) {
      continue; /* this IFU is missing completely, skip it */
    }

    unsigned short nslice;
    for (nslice = 1; nslice <= kMuseSlicesPerCCD; nslice++) {
      cpl_table_select_all(aGeo->table);
      cpl_table_and_selected_int(aGeo->table, MUSE_GEOTABLE_FIELD, CPL_EQUAL_TO, nifu);
      cpl_table_and_selected_int(aGeo->table, MUSE_GEOTABLE_CCD, CPL_EQUAL_TO, nslice);
      if (cpl_table_count_selected(aGeo->table) > 0) {
        continue; /* this slice is there */
      }
      /* add this missing slice */
      cpl_table_set_size(aGeo->table, cpl_table_get_nrow(aGeo->table) + 1);
      int irow = cpl_table_get_nrow(aGeo->table) - 1;
      cpl_table_set_int(aGeo->table, MUSE_GEOTABLE_FIELD, irow, nifu);
      cpl_table_set_int(aGeo->table, MUSE_GEOTABLE_CCD, irow, nslice);
      cpl_table_set_int(aGeo->table, MUSE_GEOTABLE_SKY, irow,
                        kMuseGeoSliceSky[nslice - 1]);
      cpl_table_set_double(aGeo->table, MUSE_GEOTABLE_X, irow, NAN);
      cpl_table_set_double(aGeo->table, MUSE_GEOTABLE_Y, irow, NAN);
      cpl_table_set_double(aGeo->table, MUSE_GEOTABLE_ANGLE, irow, 0.);
      cpl_table_set_double(aGeo->table, MUSE_GEOTABLE_WIDTH, irow, 0.);
    } /* for nslice */
  } /* for nifu */

  cpl_boolean needsnoflip = getenv("MUSE_GEOMETRY_NO_INVERSION")
                          && atoi(getenv("MUSE_GEOMETRY_NO_INVERSION")) > 0;
  if (!needsnoflip) { /* just flip all slices */
    cpl_msg_info(__func__, "Flipping all slices because of data inversion!");
    cpl_table_multiply_scalar(aGeo->table, MUSE_GEOTABLE_Y, -1.);
    cpl_table_multiply_scalar(aGeo->table, MUSE_GEOTABLE_ANGLE, -1.);
  }

  /* sort the full table, by subfield and then slice number on sky */
  cpl_propertylist *sorting = cpl_propertylist_new();
  cpl_propertylist_append_bool(sorting, MUSE_GEOTABLE_FIELD, CPL_FALSE);
  cpl_propertylist_append_bool(sorting, MUSE_GEOTABLE_SKY, CPL_FALSE);
  cpl_table_sort(aGeo->table, sorting);
  cpl_propertylist_delete(sorting);

#if 0
  printf("\nfinal geometry table with all slicer stacks [pix]:\n");
  cpl_table_dump(aGeo->table, 0, nrow, stdout);
  fflush(stdout);
#endif
#if 0
  const char *fn = "GEOMETRY_TABLE.ascii";
  FILE *fp = fopen(fn, "w");
  fprintf(fp, "# final geometry table with all slicer stacks [pix]:\n");
  cpl_table_dump(aGeo->table, 0, nrow, fp);
  fclose(fp);
  cpl_msg_debug(__func__, "written geometry table in ASCII to \"%s\"", fn);
#endif

  return CPL_ERROR_NONE;
} /* muse_geo_finalize() */

/*----------------------------------------------------------------------------*/
/**
  @private
  @brief  Fit linear relation to one table column using its error and assign
          new values to deviant entries.
  @param  aTStack   geometry table for one stack at a time
  @param  aPos      matrix of positions (sky slice numbering)
  @param  aCol      table column name of the value of interest
  @param  aErr      table column name of the related error
  @param  aLimit    error limit to use
  @param  aSigma    rejection sigma level (aSigma x RMS of linear fit)
  @return the number of replaced entries

  @note This function does no error checking!
 */
/*----------------------------------------------------------------------------*/
static unsigned int
muse_geo_correct_slices_stack(cpl_table *aTStack, cpl_matrix *aPos,
                              const char *aCol, const char *aErr, double aLimit,
                              double aSigma)
{
  const char *id = "muse_geo_correct_slices"; /* pretend to be in that function */
  /* pointers to relevant table column contents */
  double *pval = cpl_table_get_data_double(aTStack, aCol),
         *perr = cpl_table_get_data_double(aTStack, aErr);
  /* create vectors out of them for fitting */
  int ntsrow = cpl_table_get_nrow(aTStack);
  cpl_vector *val = cpl_vector_wrap(ntsrow, pval),
             *err = cpl_vector_wrap(ntsrow, perr);
  /* fit, without iteration, so use DBL_MAX for the sigma rejection level */
  double rms, chisq;
  cpl_polynomial *poly = muse_utils_iterate_fit_polynomial(aPos, val, err, NULL,
                                                           1, DBL_MAX, &rms,
                                                           &chisq);
  rms = sqrt(rms);
  cpl_vector_unwrap(val);
  cpl_vector_unwrap(err);
#if 0
  cpl_msg_debug(__func__, "poly for %s values (RMS = %f, ChiSq = %f):", aCol,
                rms, chisq);
  cpl_polynomial_dump(poly, stdout);
  fflush(stdout);
#endif
  unsigned int nreplaced = 0;
  int irow;
  for (irow = 0; irow < ntsrow; irow++) {
    double pos = cpl_matrix_get(aPos, 0, irow),
           vpoly = cpl_polynomial_eval_1d(poly, pos, NULL),
           dpoly = fabs(pval[irow] - vpoly);
    if (perr[irow] > aLimit || dpoly > aSigma * rms) {
      cpl_msg_debug(__func__, "Changing %s(%02.0f) from %.3f to %.3f "
                    "(perr = %.3f > %.3f or dpoly = %.3f > %.3f)", aCol, pos,
                    pval[irow], vpoly, perr[irow], aLimit, dpoly, aSigma * rms);
      pval[irow] = vpoly;
      nreplaced++;
    }
  } /* for irow */
  cpl_polynomial_delete(poly);
  if (nreplaced > 3) {
    cpl_msg_warning(id, "Changed %d of %d %s values in IFU %02d (stack with "
                    "sky slices %d to %d)", nreplaced, ntsrow, aCol,
                    cpl_table_get_int(aTStack, MUSE_GEOTABLE_FIELD, 0, NULL),
                    (int)cpl_matrix_get(aPos, 0, 0),
                    (int)cpl_matrix_get(aPos, 0, ntsrow - 1));
  }
  return nreplaced;
} /* muse_geo_correct_slices_stack() */

/*----------------------------------------------------------------------------*/
/**
  @brief  Correct deviant slices in an existing MUSE geometry table
  @param  aGeo      table containing full solution
  @param  aHeader   header for the input table (optional, can be NULL)
  @param  aSigma    rejection sigma level
  @return CPL_ERROR_NONE on success, another CPL error code on failure

  The input table aGeo is thought to be produced by muse_geo_finalize().

  This function first checks that all mandatory table columns are present
  (MUSE_GEOTABLE_FIELD, MUSE_GEOTABLE_CCD, MUSE_GEOTABLE_SKY, MUSE_GEOTABLE_X,
  MUSE_GEOTABLE_Y, MUSE_GEOTABLE_ANGLE, and MUSE_GEOTABLE_WIDTH, and the
  corresponding error columns).

  It then loops through the table, marking slices that belong to a given
  slicer stack of one IFU at a time. This is extracted into a separate table,
  for which all four properties (x and y position, angle and width) are then
  fitted to a linear relation (one relation per property).

  Values which have a large error estimate attached to them (the hardcoded
  values used are printed in debug output when this function is called,
  compared to the median error of each column) are the replaced using the value
  interpolated from the linear relation. The same happens to points in the
  stack which have a value that deviate more than aSigma sigma (in terms of
  RMS) from the linear relation.

  If aHeader was given, the numbers of changed slices for all of the four
  parameters are recorded as QC keywords.

  @error{set and return CPL_ERROR_NULL_INPUT, aGeo or aGeo->table are NULL}
  @error{set and return CPL_ERROR_ILLEGAL_INPUT,
         aGeo is missing one of the mandatory columns}
  @error{set and return CPL_ERROR_DATA_NOT_FOUND,
         aGeo is missing one of the mandatory columns}
  @error{set and return CPL_ERROR_INCOMPATIBLE_INPUT,
         one of the position/error columns is not of type CPL_TYPE_DOUBLE}
 */
/*----------------------------------------------------------------------------*/
cpl_error_code
muse_geo_correct_slices(muse_geo_table *aGeo, cpl_propertylist *aHeader,
                        double aSigma)
{
  cpl_ensure_code(aGeo && aGeo->table, CPL_ERROR_NULL_INPUT);
  cpl_ensure_code(aSigma > 0., CPL_ERROR_ILLEGAL_INPUT);
  cpl_ensure_code(cpl_table_has_column(aGeo->table, MUSE_GEOTABLE_FIELD) &&
                  cpl_table_has_column(aGeo->table, MUSE_GEOTABLE_CCD) &&
                  cpl_table_has_column(aGeo->table, MUSE_GEOTABLE_SKY) &&
                  cpl_table_has_column(aGeo->table, MUSE_GEOTABLE_X) &&
                  cpl_table_has_column(aGeo->table, MUSE_GEOTABLE_Y) &&
                  cpl_table_has_column(aGeo->table, MUSE_GEOTABLE_ANGLE) &&
                  cpl_table_has_column(aGeo->table, MUSE_GEOTABLE_WIDTH) &&
                  cpl_table_has_column(aGeo->table, MUSE_GEOTABLE_X"err") &&
                  cpl_table_has_column(aGeo->table, MUSE_GEOTABLE_Y"err") &&
                  cpl_table_has_column(aGeo->table, MUSE_GEOTABLE_ANGLE"err") &&
                  cpl_table_has_column(aGeo->table, MUSE_GEOTABLE_WIDTH"err"),
                  CPL_ERROR_DATA_NOT_FOUND);
  cpl_ensure_code(cpl_table_get_column_type(aGeo->table, MUSE_GEOTABLE_X) == CPL_TYPE_DOUBLE &&
                  cpl_table_get_column_type(aGeo->table, MUSE_GEOTABLE_Y) == CPL_TYPE_DOUBLE &&
                  cpl_table_get_column_type(aGeo->table, MUSE_GEOTABLE_ANGLE) == CPL_TYPE_DOUBLE &&
                  cpl_table_get_column_type(aGeo->table, MUSE_GEOTABLE_WIDTH) == CPL_TYPE_DOUBLE &&
                  cpl_table_get_column_type(aGeo->table, MUSE_GEOTABLE_X"err") == CPL_TYPE_DOUBLE &&
                  cpl_table_get_column_type(aGeo->table, MUSE_GEOTABLE_Y"err") == CPL_TYPE_DOUBLE &&
                  cpl_table_get_column_type(aGeo->table, MUSE_GEOTABLE_ANGLE"err") == CPL_TYPE_DOUBLE &&
                  cpl_table_get_column_type(aGeo->table, MUSE_GEOTABLE_WIDTH"err") == CPL_TYPE_DOUBLE,
                  CPL_ERROR_INCOMPATIBLE_INPUT);

  /* set output formats for readable screen dumps */
  cpl_table_set_column_format(aGeo->table, MUSE_GEOTABLE_X, "%8.3f");
  cpl_table_set_column_format(aGeo->table, MUSE_GEOTABLE_X"err", "%8.3f");
  cpl_table_set_column_format(aGeo->table, MUSE_GEOTABLE_Y, "%8.3f");
  cpl_table_set_column_format(aGeo->table, MUSE_GEOTABLE_Y"err", "%8.3f");
  cpl_table_set_column_format(aGeo->table, MUSE_GEOTABLE_ANGLE, "%5.3f");
  cpl_table_set_column_format(aGeo->table, MUSE_GEOTABLE_ANGLE"err", "%5.3f");
  cpl_table_set_column_format(aGeo->table, MUSE_GEOTABLE_WIDTH, "%8.3f");
  cpl_table_set_column_format(aGeo->table, MUSE_GEOTABLE_WIDTH"err", "%8.3f");

  cpl_msg_info(__func__, "Correcting %s using %.2f-sigma level",
               MUSE_TAG_GEOMETRY_TABLE, aSigma);
  cpl_msg_debug(__func__, "  median errors: x %.3f y %.3f angle %.3f width %.3f",
               cpl_table_get_column_median(aGeo->table, MUSE_GEOTABLE_X"err"),
               cpl_table_get_column_median(aGeo->table, MUSE_GEOTABLE_Y"err"),
               cpl_table_get_column_median(aGeo->table, MUSE_GEOTABLE_ANGLE"err"),
               cpl_table_get_column_median(aGeo->table, MUSE_GEOTABLE_WIDTH"err"));
  /* set up limits for the allowed error estimates */
  const double kXLimit = 0.90, /* [pix], far too large, but otherwise it changes half the slices! */
               kYLimit = 0.10, /* [pix] */
               kALimit = 0.07, /* [pix] */
               kWLimit = 0.25; /* [pix] */
  cpl_msg_debug(__func__, "  table limits:  x %.3f y %.3f angle %.3f width %.3f",
                kXLimit, kYLimit, kALimit, kWLimit);

  int nx = 0, ny = 0, na = 0, nw = 0; /* counters to track changes */
  const unsigned short nsoff = kMuseSlicesPerCCD / 4; /* slice offset */
  unsigned char nifu;
  for (nifu = 1; nifu <= kMuseNumIFUs; nifu++) {
    unsigned char nstack;
    for (nstack = 1; nstack <= 4; nstack++) {
      /* derive slice sky numbers for this stack */
      unsigned short nslice1 = (nstack - 1) * nsoff + 1,
                     nslice2 = nstack * nsoff;
      /* select whole slicer stack */
      cpl_table_unselect_all(aGeo->table);
      cpl_table_or_selected_int(aGeo->table, MUSE_GEOTABLE_FIELD, CPL_EQUAL_TO, nifu);
      cpl_table_and_selected_int(aGeo->table, MUSE_GEOTABLE_SKY,
                                 CPL_NOT_LESS_THAN, nslice1);
      cpl_table_and_selected_int(aGeo->table, MUSE_GEOTABLE_SKY,
                                 CPL_NOT_GREATER_THAN, nslice2);
      int ntsrow = cpl_table_count_selected(aGeo->table);
      cpl_msg_debug(__func__, "IFU %2hhu stack %hhu, slices %2hu to %2hu: %d rows",
                    nifu, nstack, nslice1, nslice2, ntsrow);
      if (ntsrow < 1) {
        continue; /* nothing to correct */
      }

      cpl_table *tstack = cpl_table_extract_selected(aGeo->table);
#if 0
      cpl_table_dump(tstack, 0, nsoff, stdout);
      fflush(stdout);
#endif
      /* sort the stack table by subfield and then slice number on sky *
       * (just to be really sure!)                                     */
      cpl_propertylist *sorting = cpl_propertylist_new();
      cpl_propertylist_append_bool(sorting, MUSE_GEOTABLE_FIELD, CPL_FALSE);
      cpl_propertylist_append_bool(sorting, MUSE_GEOTABLE_SKY, CPL_FALSE);
      cpl_table_sort(tstack, sorting);
      cpl_propertylist_delete(sorting);

      /* create a 1-row matrix of positions using the sky slice number */
      cpl_table_cast_column(tstack, MUSE_GEOTABLE_SKY, "skydouble", CPL_TYPE_DOUBLE);
      cpl_matrix *pos = cpl_matrix_wrap(1, ntsrow,
                                        cpl_table_get_data_double(tstack, "skydouble"));
      /* now do the linear fits and replacements of deviant entries */
      nx += muse_geo_correct_slices_stack(tstack, pos, MUSE_GEOTABLE_X,
                                          MUSE_GEOTABLE_X"err", kXLimit, aSigma);
      ny += muse_geo_correct_slices_stack(tstack, pos, MUSE_GEOTABLE_Y,
                                          MUSE_GEOTABLE_Y"err", kYLimit, aSigma);
      na += muse_geo_correct_slices_stack(tstack, pos, MUSE_GEOTABLE_ANGLE,
                                          MUSE_GEOTABLE_ANGLE"err", kALimit, aSigma);
      nw += muse_geo_correct_slices_stack(tstack, pos, MUSE_GEOTABLE_WIDTH,
                                          MUSE_GEOTABLE_WIDTH"err", kWLimit, aSigma);
      cpl_matrix_unwrap(pos);
      cpl_table_erase_column(tstack, "skydouble");
#if 0
      cpl_msg_debug(__func__, "IFU %2hhu stack %hhu, final table:", nifu, nstack);
      cpl_table_dump(tstack, 0, nsoff, stdout);
      fflush(stdout);
#endif
      cpl_table_erase_selected(aGeo->table);
      cpl_table_insert(aGeo->table, tstack, cpl_table_get_nrow(aGeo->table));
      cpl_table_delete(tstack);
    } /* for nstack */
  } /* for nifu */

  cpl_msg_info(__func__, "Changed %d x values, %d y values, %d angles, and %d "
               "widths.", nx, ny, na, nw);
  if (aHeader) {
    cpl_propertylist_update_int(aHeader, QC_GEO_SMOOTH_NX, nx);
    cpl_propertylist_update_int(aHeader, QC_GEO_SMOOTH_NY, ny);
    cpl_propertylist_update_int(aHeader, QC_GEO_SMOOTH_NA, na);
    cpl_propertylist_update_int(aHeader, QC_GEO_SMOOTH_NW, nw);
  }
  return CPL_ERROR_NONE;
} /* muse_geo_correct_slices() */

/*---------------------------------------------------------------------------*/
/**
  @brief    Add the global QC parameters to the geometry table.
  @param    aGeoTable   the output geometry table
  @param    aHeader     the header to modify
  @return   CPL_ERROR_NONE on success, another CPL error code on failure.

  The parameters are the mean value of all slice angles and the horizontal mean
  position of the central interstack gap as well as statistics for the latter.

  Since the propertylist items are all updated, it is possible to run this
  function more than once.

  @error{set and return CPL_ERROR_NULL_INPUT, aGeoTable or aHeader are NULL}
  @error{output warning\, continue with next slicer row to,
         zero or unequal slice counts for central slicer stacks in one IFU}
 */
/*---------------------------------------------------------------------------*/
cpl_error_code
muse_geo_qc_global(const muse_geo_table *aGeoTable, cpl_propertylist *aHeader)
{
  cpl_ensure_code(aGeoTable && aHeader, CPL_ERROR_NULL_INPUT);
  cpl_table *geotable = aGeoTable->table; /* shortcut */

  /* compute the positions of the central gaps */
  cpl_array *agaps = cpl_array_new(kMuseNumIFUs, CPL_TYPE_DOUBLE);
  unsigned char nifu,
                nifu1 = cpl_table_get_column_min(geotable, MUSE_GEOTABLE_FIELD),
                nifu2 = cpl_table_get_column_max(geotable, MUSE_GEOTABLE_FIELD);
  for (nifu = nifu1; nifu <= nifu2; nifu++) {
    /* select 3rd stack (2nd from left) */
    cpl_table_unselect_all(geotable);
    cpl_table_or_selected_int(geotable, MUSE_GEOTABLE_FIELD, CPL_EQUAL_TO, nifu);
    cpl_table_and_selected_int(geotable, MUSE_GEOTABLE_SKY, CPL_NOT_LESS_THAN, 13);
    cpl_table_and_selected_int(geotable, MUSE_GEOTABLE_SKY, CPL_NOT_GREATER_THAN, 24);
    cpl_table *tleft = cpl_table_extract_selected(geotable);
    /* select 2nd stack (3rd from left) */
    cpl_table_unselect_all(geotable);
    cpl_table_or_selected_int(geotable, MUSE_GEOTABLE_FIELD, CPL_EQUAL_TO, nifu);
    cpl_table_and_selected_int(geotable, MUSE_GEOTABLE_SKY, CPL_NOT_LESS_THAN, 25);
    cpl_table_and_selected_int(geotable, MUSE_GEOTABLE_SKY, CPL_NOT_GREATER_THAN, 36);
    cpl_table *tright = cpl_table_extract_selected(geotable);

    /* check for the same table size */
    int irow, nrow = cpl_table_get_nrow(tleft),
        nrow2 = cpl_table_get_nrow(tright);
    if (nrow < 1 || nrow2 < 1) {
      cpl_msg_warning(__func__, "No slices for central stacks found, cannot "
                      "compute gaps for QC in IFU %hhu", nifu);
      cpl_table_delete(tleft);
      cpl_table_delete(tright);
      continue;
    }
    if (nrow != nrow2) {
      cpl_msg_warning(__func__, "Unequal slice count for central stacks, cannot"
                      " compute gaps for QC in IFU %hhu", nifu);
      cpl_table_delete(tleft);
      cpl_table_delete(tright);
      continue;
    }
    /* both tables should be sorted, but just to be sure not to compare *
     * slices with different vertical positions, sort them again        */
    cpl_propertylist *sorting = cpl_propertylist_new();
    cpl_propertylist_append_bool(sorting, MUSE_GEOTABLE_SKY, CPL_FALSE);
    cpl_table_sort(tleft, sorting);
    cpl_table_sort(tright, sorting);
    cpl_propertylist_delete(sorting);
    cpl_array *agap = cpl_array_new(nrow, CPL_TYPE_DOUBLE);
    for (irow = 0; irow < nrow; irow++) {
      double x1 = cpl_table_get(tleft, MUSE_GEOTABLE_X, irow, NULL),
             w1 = cpl_table_get(tleft, MUSE_GEOTABLE_WIDTH, irow, NULL),
             x2 = cpl_table_get(tright, MUSE_GEOTABLE_X, irow, NULL),
             w2 = cpl_table_get(tright, MUSE_GEOTABLE_WIDTH, irow, NULL);
      cpl_array_set_double(agap, irow, (x1 + w1/2. + x2 - w2/2.) / 2.);
    } /* for irow (all rows in both slicer stacks) */
    cpl_table_delete(tleft);
    cpl_table_delete(tright);

    /* now save the mean per-slicer-row gap in the per-IFU gaps array */
    double mean = cpl_array_get_mean(agap);
    cpl_array_set_double(agaps, nifu - 1, mean);
    char *kw = cpl_sprintf(QC_GEO_IFUi_GAP, nifu);
    cpl_propertylist_update_float(aHeader, kw, mean);
    cpl_free(kw);
    cpl_array_delete(agap);
  } /* for nifu (all IFUs) */
  double gmean = cpl_array_get_mean(agaps),
         gstdev = cpl_array_get_stdev(agaps);
  cpl_propertylist_update_float(aHeader, QC_GEO_GAPS_MEAN, gmean);
  cpl_propertylist_update_float(aHeader, QC_GEO_GAPS_STDEV, gstdev);
  cpl_array_delete(agaps);

  /* compute the global mask angle */
  double angle = cpl_table_get_column_mean(geotable, MUSE_GEOTABLE_ANGLE),
         astdev = cpl_table_get_column_stdev(geotable, MUSE_GEOTABLE_ANGLE),
         amedian = cpl_table_get_column_median(geotable, MUSE_GEOTABLE_ANGLE);
  cpl_errorstate state = cpl_errorstate_get();
  cpl_propertylist_update_float(aHeader, QC_GEO_MASK_ANGLE, amedian);
  if (!cpl_errorstate_is_equal(state)) {
    /* sometimes, this keyword gets detected as double type */
    cpl_errorstate_set(state);
    cpl_propertylist_update_double(aHeader, QC_GEO_MASK_ANGLE, amedian);
  }

  cpl_msg_info(__func__, "Added global QC keywords: angle = %.3f +/- %.3f "
               "(%.3f) deg, gap positions = %.3f +/- %.3f pix", angle, astdev,
               amedian, gmean, gstdev);
  return CPL_ERROR_NONE;
} /* muse_geometry_qc_global() */

/**@}*/
