Skip to content
Snippets Groups Projects
goesr_l1b.py 26.51 KiB
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
goesr.l1b
=========

PURPOSE
Toolbox for extracting imagery from GOES-16 PUG L1B NetCDF4 files
originally goesr_pug.py as part of SIFT.

REFERENCES
GOES-R Product User's Guide Rev E

REQUIRES
numpy
netCDF4

:author: R.K.Garcia <rayg@ssec.wisc.edu>
:copyright: 2016 by University of Wisconsin Regents, see AUTHORS for more details
:license: GPLv3, see LICENSE for more details
"""
import os, sys
import logging, unittest, argparse
from collections import namedtuple
import numpy as np
# import numba
# from numba.decorators import jit
import re
from datetime import datetime, timedelta
import netCDF4 as nc4

__author__ = 'rayg'
__docformat__ = 'reStructuredText'

LOG = logging.getLogger(__name__)

# default variable names for PUG L1b files
DEFAULT_RADIANCE_VAR_NAME = 'Rad'
DEFAULT_CMI_VAR_NAME = 'CMI'
DEFAULT_Y_CENTER_VAR_NAME = 'y_image'
DEFAULT_X_CENTER_VAR_NAME = 'x_image'

geolocation = namedtuple('geolocation', ['lat', 'lon'])

# ref https://gitlab.ssec.wisc.edu/scottm/QL_package/blob/master/cspp_ql/geo_proj.py
def proj4_params(longitude_of_projection_origin=0.0, perspective_point_height=0.0,
                 semi_major_axis=0.0, semi_minor_axis=0.0,
                 sweep_angle_axis='x', latitude_of_projection_origin=0.0, y_0=0, x_0=0, **etc):
    """
    Generate PROJ.4 parameters for Fixed Grid projection
    :param longitude_of_projection_origin: longitude at center of projection, related to sub-satellite point
    :param perspective_point_height: effective projection height in m
    :param semi_major_axis: ellipsoid semi-major axis in m
    :param semi_minor_axis: ellipsoid semi-minor axis in m
    :param sweep_angle_axis: 'x' for GOES-R, 'y' for original CGMS
    :param y_0:
    :param x_0:
    :return: tuple of (key,value) pairs
    """
    assert(latitude_of_projection_origin==0.0)  # assert your assumptions: need to revisit this routine if this is not the case
    return (('proj', 'geos'),
            ('lon_0', longitude_of_projection_origin),
            ('h', perspective_point_height),
            ('a', semi_major_axis),
            ('b', semi_minor_axis),
            ('x_0', x_0),
            ('y_0', y_0),
            ('sweep', sweep_angle_axis),
            ('units', 'm'),
            # ('ellps', 'GRS80'),  # redundant given +a and +b, PUG states GRS80 and not WGS84
            )


def proj4_string(**kwargs):
    """
    see proj4_params()
    """
    p4p = proj4_params(**kwargs)
    return ' '.join( ('+%s=%s' % q) for q in p4p )
    # return '+proj=geos +sweep=x +lon_0=%f +h=%f +x_0=%d +y_0=%d +a=%f +b=%f +units=m +ellps=GRS80' % (
    #     lon_center_of_projection,
    #     perspective_point_height,
    #     x_plus,
    #     y_plus,
    #     semi_major_axis,
    #     semi_minor_axis
    # )

# @jit((numba.float32[:,:], numba.double, numba.double, numba.double, numba.double))
def _calc_bt(L: np.ndarray, fk1: float, fk2: float, bc1: float, bc2: float):
    return (fk2 / np.log((fk1 / L) + 1.0) - bc1) / bc2


def calc_bt(L: (np.ndarray, np.ma.masked_array), fk1: float, fk2: float, bc1: float, bc2: float, mask_invalid: bool=True, **etc):
    """
    convert brightness temperature bands from radiance
    ref PUG Vol4 7.1.3.1 Radiances Product : Description
    Note: marginally negative radiances can occur and will result in NaN values!
    We handle this by setting anything with radiance <=0.0 to return 0K
    :param L: raw radiance image data, preferably as masked array
    :param fk1: calibration constant
    :param fk2: calibration constant
    :param bc1: calibration constant
    :param bc2: calibration constant
    :param mask_invalid: bool, whether to include radiances <=0.0 in array mask - iff Lis masked array
    :return: BT converted radiance data, K; if input is masked_array, L<=0.0 will also be masked
    """
    T = _calc_bt(L, fk1, fk2, bc1, bc2)
    if isinstance(L, np.ma.masked_array):
        if mask_invalid:
            T = np.ma.masked_array(T, (L.data <= 0.0) | L.mask)
        else:
            T = np.ma.masked_array(T, L.mask)
    else:
        # Tmin = -bc1 / bc2   # when L<=0
        T[L <= 0.0] = 0.0  # Tmin, but for now truncate to absolute 0
    return T


# @jit((numba.float32[:,:], numba.double))
# def _calc_refl(L, kappa0):
#     return L * kappa0

def calc_refl(L: np.ndarray, kappa0: float, **etc):
    """
    convert reflectance bands from radiance
    ref PUG Vol4 7.1.3.1 Radiances Product : Description
    :param L: raw radiance image data as masked array
    :param kappa0: conversion factor radiance to reflectance
    :return:
    """
    return L * kappa0
    # if isinstance(L, np.ma.masked_array):
    #     refl = _calc_refl(L.data, kappa0)
    #     return np.ma.masked_array(refl, L.mask)
    # return _calc_refl(L, kappa0)


def is_band_refl_or_bt(band: int):
    """
    :param band: band number, 1..16
    :return: "refl", "bt" or None
    """
    return 'refl' if (1 <= band <= 6) else 'bt' if (7 <= band <= 16) else None


def nc_cal_values(nc: nc4.Dataset):
    """
    extract a dictionary of calibration parameters to use
    :param nc: PUG netCDF4 instance
    :return: dict
    """
    # grab = lambda x: x.filled(np.NAN)[...] if isinstance(x, np.ma.masked_array) else x[...]
    grab = lambda vn: np.ma.fix_invalid(nc[vn][:], fill_value=np.NAN)[...]
    return {
        'fk1': grab('planck_fk1'), # nc['planck_fk1'][:].filled(np.NAN)[...],
        'fk2': grab('planck_fk2'), # nc['planck_fk2'][:].filled(np.NAN)[...],
        'bc1': grab('planck_bc1'), # nc['planck_bc1'][:].filled(np.NAN)[...],
        'bc2': grab('planck_bc2'), # nc['planck_bc2'][:].filled(np.NAN)[...],
        'kappa0': grab('kappa0'), # nc['kappa0'][:].filled(np.NAN)[...]
    }


def nc_nav_values(nc, primary_var_name: str=None,
                  proj_var_name: str=None,
                  y_image_var_name: str=DEFAULT_Y_CENTER_VAR_NAME,
                  x_image_var_name: str=DEFAULT_X_CENTER_VAR_NAME):
    """
    extract a dictionary of navigation parameters to use
    :param primary_var_name: radiance variable name to follow .grid_mapping of, optional with default
    :param proj_var_name: alternately, projection variable to fetch parameters from
    :param y_image_var_name: variable holding center of image in FGF y/x radiance, if available
    :param x_image_var_name: variable holding center of image in FGF y/x radiance, if available
    :param nc: PUG netCDF4 instance
    :return: dict
    FUTURE: distinguish navigation from projection
    """
    if proj_var_name is None:
        v = nc.variables.get(primary_var_name or DEFAULT_RADIANCE_VAR_NAME, None)
        if v is not None:
            a = getattr(v, 'grid_mapping', None)
            if a is not None:
                proj_var_name = a
    if proj_var_name is None:
        raise ValueError('unknown projection variable to read')
    proj = nc[proj_var_name]
    nav = dict((name, getattr(proj, name, None)) for name in (
        'grid_mapping_name', 'perspective_point_height', 'semi_major_axis', 'semi_minor_axis',
        'inverse_flattening', 'latitude_of_projection_origin', 'longitude_of_projection_origin', 'sweep_angle_axis'))

    # x_0, y_0 = 0, 0
    # false easting / false northing
    # if y_image_var_name in nc.variables.keys():
    #     y_0 = float(nc[y_image_var_name][:]) * nav['perspective_point_height']  # convert to nadir-meter for proj4
    # if x_image_var_name in nc.variables.keys():
    #     x_0 = float(nc[x_image_var_name][:]) * nav['perspective_point_height']
    # nav['y_0'], nav['x_0'] = y_0, x_0

    return nav

def nc_y_x_names(nc, primary_var_name: str=None):
    """
    read .coordinates from variable to get names of y and x variables
    note that PUG files may have multiple named coordinate variables for band/time preceding
    :param nc: netcdf file
    :param primary_var_name: variable of interest, typically DEFAULT_RADIANCE_VAR_NAME or DEFAULT_CMI_VAR_NAME
    :return:
    """
    v = nc.variables.get(primary_var_name or DEFAULT_RADIANCE_VAR_NAME, None)
    c = v.coordinates.split()
    return c[-2:]




RE_PUG_ISO = re.compile(r'(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)\.(\d+)Z')

def timecode_to_datetime(iso):
    m = RE_PUG_ISO.match(iso.strip())
    nums = list(m.groups())
    nums[6] += '0' * max(0,6-len(nums[6]))  # .9 -> 900000µs
    yyyy, mm, dd, h, m, s, t = map(int, nums)
    return datetime(yyyy, mm, dd, h, m, s, t)


DEFAULT_SNAP_SECONDS = 60
SNAP_SECONDS = {'Full Disk': 5*60,
                'Mesoscale': 15,
                }

def snap_scene_onto_schedule(start: datetime, end: datetime, scene_id: str=None, timeline_id: str=None):
    """
    assign a "timeline" time to an image by looking at its coverage
     currently this is done by taking the center time and dropping it to the previous
    :param start: datetime object, time_coverage_start
    :param end: datetime
    :param scene_id: string, typically 'Full Disk'
    :param timeline_id: instrument mode, typically "Mode 3" or "Mode 4"
    :return: datetime of 'timeline' time
    """
    c = start # + (end - start)/2
    b = datetime(c.year, c.month, c.day, c.hour)
    d = (c - b).total_seconds()  # seconds within hour of image start time
    sns = SNAP_SECONDS.get(scene_id.strip(), DEFAULT_SNAP_SECONDS)  # resolution to snap to
    d = int(d) - (int(d) % sns)
    m = b + timedelta(seconds=d)
    return m


def resolution_from_attr(spatial_resolution: str):
    return {'4km at nadir': 4000,
            '4.0km at nadir': 4000,
            '2km at nadir': 2000,
            '2.0km at nadir': 2000,
            '1km at nadir': 1000,
            '1.0km at nadir': 1000,
            '500m at nadir': 500,
            '0.5km at nadir': 500,}[spatial_resolution]


def mode_from_timeline_id(timeline_id):
    m = re.match(r'ABI Mode (\d+)', timeline_id)
    return int(m.group(1))

def scene_from_dataset_name(dataset_name):
    """
    M1, M2, F, C, or None
    """
    m = re.search(r'-(?:CMIP|Rad)(\w+)-M', dataset_name or "")
    return None if m is None else m.group(1)

class PugFile(object):
    """
    PUG helper routines for a single band, given a single-band NetCDF4 file
    or a multi-band NetCDF4 file and an offset in the band dimension
    """
    path = None  # file or glob used to access this data
    nc = None  # if we opened the file for you, it's here
    _band_offset = None
    band = None  # band number
    central_wavelength = None  # um
    platform_id = None  # platform_ID as string
    sched_time = None  # datetime object, nominal/normalized/snapped time of the image onto a 1min/5min/10min/15min timeline based on mode/scene
    time_span = None  # (start,end) datetime pair
    timeline_id = None  # string, instrument mode
    bit_depth = None  # bit depth
    shape_m = None  # nadir-meter dy,dx approximation : FUTURE: deprecate this toward cell_size, too confusing vs .shape being whole array
    scene_id = None  # string, type of scene e.g. "Full Disk"
    resolution = None  # approximate nadir meters: 500, 1000, 2000, 4000
    display_time = None  # str form of timeline YYYY-mm-dd HH:MM
    display_name = None  # str form of platform + scene + band
    instrument_type = None  # long identifier for instrument
    instrument_name = None  # ABI, AHI, AMI, etc

    cal = None  # dictionary of cal parameters, only present on l1b files
    nav = None  # dictionary of nav parameters
    shape = None  # (lines, elements)
    primary_var_name = None  # Rad or CMI variable to focus on

    @property
    def bt_or_refl(self):
        return is_band_refl_or_bt(self.band)

    @property
    def kind(self):
        return self.bt_or_refl

    def __init__(self, nc: (nc4.Dataset, str), primary_var_name: str=None, band_offset: int=0):
        """
        Initialize cal and nav helper object from a PUG NetCDF4 L1B file.
        Can be used as a cal/nav/metadata fetch, or as a file wrapper.
        :param nc: netCDF4 Dataset if only metadata is desired; netcdf pathname if file should be opened and retained
        :param primary_var_name: radiance variable to convert to BT/Refl
        :param band_offset: typically 0, eventually nonzero for multi-band files
        """
        # FUTURE: handle band dimension more effectively, including surveying metadata for multi-band files
        super(PugFile, self).__init__()
        if not isinstance(nc, nc4.Dataset):  # then we can open and retain the file for our use
            self.path = nc
            nc = nc4.Dataset(nc)
            self.nc = nc
        else:
            self.path = nc.filepath()
        self.primary_var_name = primary_var_name
        primary_var = nc[primary_var_name]
        self._band_offset = band_offset
        self.band = int(nc['band_id'][band_offset])  # FUTURE: this has a band dimension
        self.nav = nc_nav_values(nc, primary_var_name)
        self.shape = primary_var.shape[-2:]
        self.platform_id = nc.platform_ID
        self.instrument_type = nc.instrument_type
        self.instrument_name = "ABI" if "Himawari" not in self.instrument_type else "AHI"
        self.time_span = st, en = timecode_to_datetime(nc.time_coverage_start), timecode_to_datetime(nc.time_coverage_end)
        try:
            self.timeline_id = nc.timeline_id
            self.mode = mode_from_timeline_id(self.timeline_id)
            scene_from_dataset = scene_from_dataset_name(nc.dataset_name)
            self.meso_scene = int(scene_from_dataset[1:]) if scene_from_dataset.startswith('M') else None
        except AttributeError as probably_himawari:
            LOG.warning("no timeline_id; assuming AXI Mode 1 placeholder")
            self.timeline_id = "AXI Mode 1 presumed"
            self.mode = 1
            self.meso_scene = None
            raise
        self.scene_id = nc.scene_id
        self.resolution = resolution_from_attr(nc.spatial_resolution)
        self.sched_time = snap_scene_onto_schedule(st, en, self.scene_id, self.timeline_id)
        self.display_time = st.strftime('%Y-%m-%d %H:%M:%S')
        self.display_name = '{} {} B{:02d} {} {}'.format(
            self.platform_id, self.instrument_name,
            self.band, self.scene_id, self.display_time)
        self.y_var_name, self.x_var_name = nc_y_x_names(nc, self.primary_var_name)
        self.bit_depth = nc[self.primary_var_name].sensor_band_bit_depth
        self.central_wavelength = nc['band_wavelength'][...]  # um
        try:
            dy, dx = float(nc[self.y_var_name].scale_factor), float(nc[self.x_var_name].scale_factor)
            hgt = float(self.nav['perspective_point_height'])
            self.shape_m = hgt * dy, hgt * dx
        except AttributeError as not_scaled:
            LOG.warning('no scale_factor on y and x variables')
            pass

    @property
    def proj4(self):
        return proj4_string(**self.nav)

    @property
    def proj4_params(self):
        return proj4_params(**self.nav)

    @property
    def proj4_string(self):
        return proj4_string(**self.nav)

    @property
    def data(self):
        raise NotImplementedError('not implemented in PugFile')

    @property
    def bt(self):
        raise NotImplementedError('not implemented in PugFile')

    @property
    def refl(self):
        raise NotImplementedError('not implemented in PugFile')

    @property
    def rad(self):
        raise NotImplementedError('not implemented in PugFile')

    @property
    def y(self):
        if None is self.nc:
            return None
        return self.nc[self.y_var_name][:]

    @property
    def x(self):
        if None is self.nc:
            return None
        return self.nc[self.x_var_name][:]

    @property
    def yx_center(self):
        if None is self.nc:
            return None, None
        y, x = self.y, self.x
        return np.nanmean(y), np.nanmean(x)
        # return self.nc['y_image'][...], self.nc['x_image'][...]  # GOTCHA: returns projection center, not scene center

    @property
    def perspective_point_height(self):
        return float(self.nav['perspective_point_height'])

    @property
    def proj_y(self):
        """
        projection coordinate as cartesian nadir-meters as used by PROJ.4
        ref http://proj4.org/projections/geos.html
        scanning_angle (radians) = projection_coordinate / h
        """
        return np.require(self.y, dtype=np.float64) * self.perspective_point_height

    @property
    def proj_x(self):
        """
        projection coordinate as cartesian nadir-meters as used by PROJ.4
        ref http://proj4.org/projections/geos.html
        scanning_angle (radians) = projection_coordinate / h
        """
        return np.require(self.x, dtype=np.float64) * self.perspective_point_height

    @property
    def cell_size(self):
        """
        :return: (y, x) nadir-meters nominal size of a cell
        """
        return self.shape_m
        # if None is self.nc:
        #     return None, None
        # ym = float(self.nc[self.y_var_name].scale_factor)
        # xm = float(self.nc[self.x_var_name].scale_factor)
        # h = self.perspective_point_height
        # return ym * h, xm * h

    @property
    def origin(self):
        """
        :return: (y, x) nadir-meters nominal size of a cell
        """
        if None is self.nc:
            return None, None
        h = self.perspective_point_height
        yo, xo = h * self.y[0], h * self.x[0]
        return yo, xo

    @property
    def proj(self):
        from pyproj import Proj
        return Proj(projparams=dict(self.proj4_params))

    @property
    def latlon_center(self):
        proj = self.proj
        h = float(dict(self.proj4_params)['h'])
        y, x = self.yx_center  # gotcha: gives projection center for meso scenes
        lon, lat = proj(x * h, y * h, inverse=True)
        return geolocation(lon=lon, lat=lat)

    @property
    def geo(self):
        proj = self.proj
        y_m = self.proj_y  # nadir-meter
        x_m = self.proj_x
        w,h = len(x_m), len(y_m)
        y = np.broadcast_to(y_m, (w, h)).transpose()
        x = np.broadcast_to(x_m, (h, w))
        lon, lat = proj(x,y, inverse=True)
        return geolocation(lon=lon, lat=lat)

    def lat_lon_to_l_c(self, lat, lon, round=True):
        """
        convert latitude-longitude all the way back to line-column by reversing projection and y/x scaling
        :param lat: latitude value or array in degrees
        :param lon: longitude value or array in degrees
        :return: line, column array indexed 0..height-1, 0..width-1
        """
        proj = self.proj
        # convert to nadir-meters using proj4
        xm, ym = proj(lon, lat, inverse=False)
        # divide out height to get back to projection view angles
        h = self.perspective_point_height
        y, x = ym / h, xm / h
        # extract scale factor and add offset to back out to line-from-0, column-from-0
        yvar, xvar = self.nc[self.y_var_name], self.nc[self.x_var_name]
        ysf, yao = yvar.scale_factor, yvar.add_offset
        xsf, xao = xvar.scale_factor, xvar.add_offset
        # reverse scale factor and add offset to get line and column
        line, column = (y - yao) / ysf, (x - xao) / xsf
        # round to nearest integer
        if round:
            return np.round(line).astype(np.int32), np.round(column).astype(np.int32)
        else:  # return floating point values
            return line, column

    def walk(self, as_cmi=False):
        from goesr.rockfall import nc_walk
        # LOG.warning("as_cmi band_id dimension not yet implemented")
        for pvda in nc_walk(self.nc):
            yield pvda

    @staticmethod
    def attach(path):
        basename = os.path.split(path)[-1]
        is_l1b = 'L1b-Rad' in basename
        return PugL1bTools(path) if is_l1b else PugCmiTools(path)


class PugL1bTools(PugFile):
    """
    PUG helper routines for a single band, given a single-band NetCDF4 file
    or a multi-band NetCDF4 file and an offset in the band dimension
    """

    def __init__(self, nc: (nc4.Dataset, str), primary_var_name: str=DEFAULT_RADIANCE_VAR_NAME, band_offset: int=0):
        """
        Initialize cal and nav helper object from a PUG NetCDF4 L1B file.
        Can be used as a cal/nav/metadata fetch, or as a file wrapper.
        :param nc: netCDF4 Dataset if only metadata is desired; netcdf pathname if file should be opened and retained
        :param primary_var_name: radiance variable to convert to BT/Refl
        :param band_offset: typically 0, eventually nonzero for multi-band files
        """
        owns = False
        if not isinstance(nc, nc4.Dataset):  # then we can open and retain the file for our use
            nc = nc4.Dataset(nc)
            owns = True
        super(PugL1bTools, self).__init__(nc, primary_var_name, band_offset)
        if not owns:
            self.nc = None
        else:
            self.nc = nc
        self.cal = nc_cal_values(nc)

    def convert_radiances(self, rad):
        """
        calculate BT or Refl based on band
        :param rad: radiance array from file
        :return: ('refl' or 'bt', measurement array, 'K' or '')
        """
        kind = self.bt_or_refl
        if kind == 'refl':
            return kind, calc_refl(rad, **self.cal), ''
        elif kind == 'bt':
            return kind, calc_bt(rad, **self.cal), 'K'

    def convert_from_nc(self, nc: nc4.Dataset=None):
        nc = nc or self.nc
        if not nc:
            raise ValueError('must provide a valid netCDF file')
        rad = nc[self.primary_var_name][:]
        return self.convert_radiances(rad)

    @property
    def data(self):
        if None is self.nc:
            return None
        _, measurement_values, _ = self.convert_from_nc(self.nc)
        return measurement_values

    @property
    def bt(self):
        if None is self.nc:
            return None
        if 'bt' == self.bt_or_refl:
            return self.convert_from_nc(self.nc)[1]
        LOG.warning('cannot request bt from non-emissive band')
        return None

    @property
    def refl(self):
        if None is self.nc:
            return None
        if 'refl' == self.bt_or_refl:
            return self.convert_from_nc(self.nc)[1]
        LOG.warning('cannot request refl from non-reflectance band')
        return None

    @property
    def rad(self):
        if None is self.nc:
            return None
        return self.nc[self.primary_var_name][:]


class PugCmiTools(PugFile):
    """
    PUG helper routines for a single band, given a single-band NetCDF4 file
    or a multi-band NetCDF4 file and an offset in the band dimension
    """

    def __init__(self, nc: (nc4.Dataset, str), primary_var_name: str=DEFAULT_CMI_VAR_NAME, band_offset: int=0):
        """
        Initialize cal and nav helper object from a PUG NetCDF4 L1B file.
        Can be used as a cal/nav/metadata fetch, or as a file wrapper.
        :param nc: netCDF4 Dataset if only metadata is desired; netcdf pathname if file should be opened and retained
        :param primary_var_name: radiance variable to convert to BT/Refl
        :param band_offset: typically 0, eventually nonzero for multi-band files
        """
        owns = False
        if not isinstance(nc, nc4.Dataset):  # then we can open and retain the file for our use
            nc = nc4.Dataset(nc)
            owns = True
        super(PugCmiTools, self).__init__(nc, primary_var_name, band_offset)
        if not owns:
            self.nc = None
        else:
            self.nc = nc
        self.cal = {} # nc_cal_values(nc)

    def _data_for_band(self, band_offset=None):
        band_offset = band_offset or self._band_offset
        ncv = self.nc[self.primary_var_name]
        if len(ncv.shape)==3:
            return ncv[band_offset,:,:].squeeze()
        else:
            if band_offset != 0:
                raise ValueError("single-band file cannot have band_offset != 0")
            return ncv[:,:]

    @property
    def data(self):
        if None is self.nc:
            return None
        return self._data_for_band()

    @property
    def bt(self):
        if None is self.nc:
            return None
        if 'bt' == self.bt_or_refl:
            return self._data_for_band()
        LOG.warning('cannot request bt from non-emissive band')
        return None

    @property
    def refl(self):
        if None is self.nc:
            return None
        if 'refl' == self.bt_or_refl:
            return self._data_for_band()
        LOG.warning('cannot request refl from non-reflectance band')
        return None

    @property
    def rad(self):
        return None


def goesr_to_fbf(filename):
    pug = PugFile.attach(filename)
    h,w = pug.shape
    geo = pug.geo
    latitude = np.memmap('latitude.real4.{}.{}'.format(w,h), dtype=np.float32, shape=(h,w), mode='write')
    longitude = np.memmap('longitude.real4.{}.{}'.format(w,h), dtype=np.float32, shape=(h,w), mode='write')
    latitude[:] = geo.lat
    longitude[:] = geo.lon
    latitude.flush()
    longitude.flush()
    if pug.bt_or_refl=='bt':
        data = pug.bt
        vn = 'brightness_temp'
    elif pug.bt_or_refl=='refl':
        data = pug.refl
        vn = 'reflectance'
    else:
        data = None
    output = np.memmap('{}.real4.{}.{}'.format(vn, w, h), dtype=np.float32, shape=(h,w), mode='write')
    output[:] = data
    output.flush()
    prad = pug.rad
    if prad is not None:
        rad = np.memmap('radiance.real4.{}.{}'.format(w, h), dtype=np.float32, shape=(h,w), mode='write')
        rad[:] = np.ma.fix_invalid(pug.rad, fill_value=np.NAN)
        rad.flush()


def _debug(type, value, tb):
    "enable with sys.excepthook = debug"
    if not sys.stdin.isatty():
        sys.__excepthook__(type, value, tb)
    else:
        import traceback, pdb
        traceback.print_exception(type, value, tb)
        # …then start the debugger in post-mortem mode.
        pdb.post_mortem(tb)  # more “modern”


def main():
    parser = argparse.ArgumentParser(
        description="PURPOSE",
        epilog="",
        fromfile_prefix_chars='@')
    parser.add_argument('-v', '--verbose', dest='verbosity', action="count", default=0,
                        help='each occurrence increases verbosity 1 level through ERROR-WARNING-INFO-DEBUG')
    parser.add_argument('-d', '--debug', dest='debug', action='store_true',
                        help="enable interactive PDB debugger on exception")
    # http://docs.python.org/2.7/library/argparse.html#nargs
    # parser.add_argument('--stuff', nargs='5', dest='my_stuff',
    #                    help="one or more random things")
    parser.add_argument('inputs', nargs='*',
                        help="input file to process")
    args = parser.parse_args()

    levels = [logging.ERROR, logging.WARN, logging.INFO, logging.DEBUG]
    logging.basicConfig(level=levels[min(3, args.verbosity)])

    if args.debug:
        sys.excepthook = _debug

    if not args.inputs:
        unittest.main()
        return 0

    for pn in args.inputs:
        goesr_to_fbf(pn)

    return 0


if __name__ == '__main__':
    sys.exit(main())