[hdf-compass] 247/295: Extend the compass model with GeoArray and GeoSurface

Ghislain Vaillant ghisvail-guest at moszumanska.debian.org
Sun May 8 10:35:50 UTC 2016


This is an automated email from the git hooks/post-receive script.

ghisvail-guest pushed a commit to branch debian/master
in repository hdf-compass.

commit e1abee4d260ea2609f8b7a94ccb1b44e2874cd1f
Author: giumas <giumas at yahoo.it>
Date:   Fri Nov 6 23:49:34 2015 -0500

    Extend the compass model with GeoArray and GeoSurface
---
 HDFCompass.1file.spec                              |   4 +-
 HDFCompass.1folder.spec                            |   2 +-
 README.rst                                         |   1 +
 docs/conf.py                                       |   2 +-
 hdf_compass/bag_model/model.py                     | 189 +++++++++-
 hdf_compass/compass_model/__init__.py              |   3 +-
 hdf_compass/compass_model/model.py                 |  63 +++-
 .../geo_array}/__init__.py                         |   4 +-
 hdf_compass/compass_viewer/geo_array/frame.py      | 416 +++++++++++++++++++++
 hdf_compass/compass_viewer/geo_array/plot.py       | 207 ++++++++++
 .../geo_surface}/__init__.py                       |   4 +-
 hdf_compass/compass_viewer/geo_surface/frame.py    | 416 +++++++++++++++++++++
 hdf_compass/compass_viewer/geo_surface/plot.py     | 213 +++++++++++
 hdf_compass/compass_viewer/viewer.py               |  10 +-
 hdf_compass/utils/__init__.py                      |   2 +-
 setup.cfg                                          |  15 +-
 setup.py                                           |   5 +-
 17 files changed, 1524 insertions(+), 32 deletions(-)

diff --git a/HDFCompass.1file.spec b/HDFCompass.1file.spec
index f4c62d4..0595c9c 100644
--- a/HDFCompass.1file.spec
+++ b/HDFCompass.1file.spec
@@ -59,12 +59,12 @@ else:
 if not os.path.exists(icon_file):
     raise RuntimeError("invalid path to icon: %s" % icon_file)
 
-version = '0.6.0.dev1'
+version = '0.6.0b2'
 app_name = 'HDFCompass_' + version
 
 a = Analysis(['HDFCompass.py'],
              pathex=[],
-             hiddenimports=[],
+             hiddenimports=['scipy.linalg.cython_blas', 'scipy.linalg.cython_lapack'],  # for cartopy
              excludes=["PySide"],  # exclude libraries from being bundled (in case that are installed)
              hookspath=None,
              runtime_hooks=None)
diff --git a/HDFCompass.1folder.spec b/HDFCompass.1folder.spec
index e492c48..c9ea9ba 100644
--- a/HDFCompass.1folder.spec
+++ b/HDFCompass.1folder.spec
@@ -61,7 +61,7 @@ if not os.path.exists(icon_file):
 
 a = Analysis(['HDFCompass.py'],
              pathex=[],
-             hiddenimports=[],
+             hiddenimports=['scipy.linalg.cython_blas', 'scipy.linalg.cython_lapack'],  # for cartopy
              excludes=["PySide"],  # exclude libraries from being bundled (in case that are installed)
              hookspath=None,
              runtime_hooks=None)
diff --git a/README.rst b/README.rst
index 8536d8e..2a2c69e 100644
--- a/README.rst
+++ b/README.rst
@@ -41,6 +41,7 @@ You will need:
 * `Python 2.7 <https://www.python.org/downloads/>`_ *(support for Python 3.4+ in progress)*
 * `NumPy <https://github.com/numpy/numpy>`_
 * `Matplotlib <https://github.com/matplotlib/matplotlib>`_
+* `Cartopy <https://github.com/SciTools/cartopy>`_
 * `wxPython Phoenix <https://github.com/wxWidgets/Phoenix>`_ *(2.9.5.0 or later)*
 * `h5py <https://github.com/h5py/h5py>`_ *[HDF plugin]*
 * `hydroffice.bag <https://bitbucket.org/ccomjhc/hyo_bag>`_ *[BAG plugin]*
diff --git a/docs/conf.py b/docs/conf.py
index f127e90..a9378fa 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -57,7 +57,7 @@ author = u'The HDF Group'
 # The short X.Y version.
 version = '0.6'
 # The full version, including alpha/beta/rc tags.
-release = '0.6.0.dev1'
+release = '0.6.0b2'
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
diff --git a/hdf_compass/bag_model/model.py b/hdf_compass/bag_model/model.py
index 26a35e7..cb345fe 100644
--- a/hdf_compass/bag_model/model.py
+++ b/hdf_compass/bag_model/model.py
@@ -282,7 +282,95 @@ class BAGDataset(compass_model.Array):
         return True
 
 
-class BAGElevation(compass_model.Array):
+class BAGElevationArray(compass_model.Array):
+    """ Represents a BAG elevation. """
+    class_kind = "BAG Elevation [array]"
+
+    @staticmethod
+    def can_handle(store, key):
+        return (key == "/BAG_root/elevation") and (key in store) and (isinstance(store.f[key], h5py.Dataset))
+
+    def __init__(self, store, key):
+        self._store = store
+        self._key = key
+        self._dset = store.f.elevation(mask_nan=True)
+
+    @property
+    def key(self):
+        return self._key
+
+    @property
+    def store(self):
+        return self._store
+
+    @property
+    def display_name(self):
+        return pp.basename(self.key)
+
+    @property
+    def description(self):
+        return 'Dataset "%s"' % (self.display_name,)
+
+    @property
+    def shape(self):
+        return self._dset.shape
+
+    @property
+    def dtype(self):
+        return self._dset.dtype
+
+    def __getitem__(self, args):
+        return self._dset[args]
+
+
+class BAGElevationGeoArray(compass_model.GeoArray):
+    """ Represents a BAG elevation. """
+    class_kind = "BAG Elevation [geo array]"
+
+    @staticmethod
+    def can_handle(store, key):
+        return (key == "/BAG_root/elevation") and (key in store) and (isinstance(store.f[key], h5py.Dataset))
+
+    def __init__(self, store, key):
+        self._store = store
+        self._key = key
+        self._dset = store.f.elevation(mask_nan=True)
+        self._meta = store.f.populate_metadata()
+
+    @property
+    def key(self):
+        return self._key
+
+    @property
+    def store(self):
+        return self._store
+
+    @property
+    def display_name(self):
+        return pp.basename(self.key)
+
+    @property
+    def description(self):
+        return 'Dataset "%s"' % (self.display_name,)
+
+    @property
+    def shape(self):
+        return self._dset.shape
+
+    @property
+    def dtype(self):
+        return self._dset.dtype
+
+    @property
+    def extent(self):
+        """ Geographic extent as a tuple: (x_min, x_max, y_min, y_max) """
+        return self._meta.geo_extent()
+
+    def __getitem__(self, args):
+        return self._dset[args]
+
+
+class BAGElevation(compass_model.GeoSurface):
     """ Represents a BAG elevation. """
     class_kind = "BAG Elevation"
 
@@ -294,6 +382,53 @@ class BAGElevation(compass_model.Array):
         self._store = store
         self._key = key
         self._dset = store.f.elevation(mask_nan=True)
+        self._meta = store.f.populate_metadata()
+
+    @property
+    def key(self):
+        return self._key
+
+    @property
+    def store(self):
+        return self._store
+
+    @property
+    def display_name(self):
+        return pp.basename(self.key)
+
+    @property
+    def description(self):
+        return 'Dataset "%s"' % (self.display_name,)
+
+    @property
+    def shape(self):
+        return self._dset.shape
+
+    @property
+    def dtype(self):
+        return self._dset.dtype
+
+    @property
+    def extent(self):
+        """ Geographic extent as a tuple: (x_min, x_max, y_min, y_max) """
+        return self._meta.geo_extent()
+
+    def __getitem__(self, args):
+        return self._dset[args]
+
+
+class BAGUncertaintyArray(compass_model.Array):
+    """ Represents an uncertainty array. """
+    class_kind = "BAG Uncertainty [array]"
+
+    @staticmethod
+    def can_handle(store, key):
+        return (key == "/BAG_root/uncertainty") and (key in store) and (isinstance(store.f[key], h5py.Dataset))
+
+    def __init__(self, store, key):
+        self._store = store
+        self._key = key
+        self._dset = store.f.uncertainty(mask_nan=True)
 
     @property
     def key(self):
@@ -501,7 +636,48 @@ class BAGMetadataXml(compass_model.Xml):
         return self.store.f.validation_info()
 
 
-class BAGUncertainty(compass_model.Array):
+class BAGUncertaintyArray(compass_model.Array):
+    """ Represents an uncertainty array. """
+    class_kind = "BAG Uncertainty [array]"
+
+    @staticmethod
+    def can_handle(store, key):
+        return (key == "/BAG_root/uncertainty") and (key in store) and (isinstance(store.f[key], h5py.Dataset))
+
+    def __init__(self, store, key):
+        self._store = store
+        self._key = key
+        self._dset = store.f.uncertainty(mask_nan=True)
+
+    @property
+    def key(self):
+        return self._key
+
+    @property
+    def store(self):
+        return self._store
+
+    @property
+    def display_name(self):
+        return pp.basename(self.key)
+
+    @property
+    def description(self):
+        return 'Dataset "%s"' % (self.display_name,)
+
+    @property
+    def shape(self):
+        return self._dset.shape
+
+    @property
+    def dtype(self):
+        return self._dset.dtype
+
+    def __getitem__(self, args):
+        return self._dset[args]
+
+
+class BAGUncertainty(compass_model.GeoArray):
     """ Represents a BAG uncertainty. """
     class_kind = "BAG Uncertainty"
 
@@ -513,6 +689,7 @@ class BAGUncertainty(compass_model.Array):
         self._store = store
         self._key = key
         self._dset = store.f.uncertainty(mask_nan=True)
+        self._meta = store.f.populate_metadata()
 
     @property
     def key(self):
@@ -538,6 +715,11 @@ class BAGUncertainty(compass_model.Array):
     def dtype(self):
         return self._dset.dtype
 
+    @property
+    def extent(self):
+        """ Geographic extent as a tuple: (x_min, x_max, y_min, y_max) """
+        return self._meta.geo_extent()
+
     def __getitem__(self, args):
         return self._dset[args]
 
@@ -642,7 +824,10 @@ class BAGImage(compass_model.Image):
 # Register handlers
 BAGStore.push(BAGKV)
 BAGStore.push(BAGDataset)
+BAGStore.push(BAGElevationArray)
+BAGStore.push(BAGElevationGeoArray)
 BAGStore.push(BAGElevation)
+BAGStore.push(BAGUncertaintyArray)
 BAGStore.push(BAGUncertainty)
 BAGStore.push(BAGTrackinList)
 BAGStore.push(BAGMetadataRaw)
diff --git a/hdf_compass/compass_model/__init__.py b/hdf_compass/compass_model/__init__.py
index 0853f0a..c18b5b0 100644
--- a/hdf_compass/compass_model/__init__.py
+++ b/hdf_compass/compass_model/__init__.py
@@ -11,7 +11,8 @@
 ##############################################################################
 from __future__ import absolute_import, division, print_function, unicode_literals
 
-from .model import get_stores, push, Store, Node, Container, KeyValue, Array, Text, Xml, Image, Unknown
+from .model import get_stores, push, Store, Node, Container, KeyValue, \
+    GeoArray, GeoSurface, Array, Text, Xml, Image, Unknown
 
 import logging
 log = logging.getLogger(__name__)
diff --git a/hdf_compass/compass_model/model.py b/hdf_compass/compass_model/model.py
index 82d49a7..4e32c88 100644
--- a/hdf_compass/compass_model/model.py
+++ b/hdf_compass/compass_model/model.py
@@ -392,10 +392,67 @@ class Array(Node):
         return True
 
 
+class GeoArray(Node):
+    """ Represents a NumPy-style regular, rectangular array with a known geographic extent. """
+
+    __metaclass__ = ABCMeta
+
+    icons = {16:    os.path.join(icon_folder, "array_16.png"),
+             64:    os.path.join(icon_folder, "array_64.png")}
+
+    @property
+    def shape(self):
+        """ Shape tuple """
+        raise NotImplementedError
+
+    @property
+    def dtype(self):
+        """ Data type """
+        raise NotImplementedError
+
+    @property
+    def extent(self):
+        """ Geographic extent as a tuple: (x_min, x_max, y_min, y_max) """
+        raise NotImplementedError
+
+    def __getitem__(self, args):
+        """ Retrieve data elements """
+        raise NotImplementedError
+
+    def is_plottable(self):
+        """ To be overriden in case that there are cases in which the array is not plottable """
+        return True
+
+
+class GeoSurface(Node):
+    """ Represents a NumPy-style regular, rectangular surface with a known geographic extent. """
+
+    __metaclass__ = ABCMeta
+
+    icons = {16:    os.path.join(icon_folder, "array_16.png"),
+             64:    os.path.join(icon_folder, "array_64.png")}
+
+    @property
+    def shape(self):
+        """ Shape tuple """
+        raise NotImplementedError
+
+    @property
+    def dtype(self):
+        """ Data type """
+        raise NotImplementedError
+
+    def __getitem__(self, args):
+        """ Retrieve data elements """
+        raise NotImplementedError
+
+    def is_plottable(self):
+        """ To be overriden in case that there are cases in which the array is not plottable """
+        return True
+
+
 class Image(Node):
-    """
-    A single raster image.
-    """
+    """ A single raster image. """
 
     __metaclass__ = ABCMeta
 
diff --git a/hdf_compass/compass_model/__init__.py b/hdf_compass/compass_viewer/geo_array/__init__.py
similarity index 87%
copy from hdf_compass/compass_model/__init__.py
copy to hdf_compass/compass_viewer/geo_array/__init__.py
index 0853f0a..aa3d0ec 100644
--- a/hdf_compass/compass_model/__init__.py
+++ b/hdf_compass/compass_viewer/geo_array/__init__.py
@@ -11,8 +11,8 @@
 ##############################################################################
 from __future__ import absolute_import, division, print_function, unicode_literals
 
-from .model import get_stores, push, Store, Node, Container, KeyValue, Array, Text, Xml, Image, Unknown
+from .frame import GeoArrayFrame
 
 import logging
 log = logging.getLogger(__name__)
-log.addHandler(logging.NullHandler())
+log.addHandler(logging.NullHandler())
\ No newline at end of file
diff --git a/hdf_compass/compass_viewer/geo_array/frame.py b/hdf_compass/compass_viewer/geo_array/frame.py
new file mode 100644
index 0000000..ccefe99
--- /dev/null
+++ b/hdf_compass/compass_viewer/geo_array/frame.py
@@ -0,0 +1,416 @@
+##############################################################################
+# Copyright by The HDF Group.                                                #
+# All rights reserved.                                                       #
+#                                                                            #
+# This file is part of the HDF Compass Viewer. The full HDF Compass          #
+# copyright notice, including terms governing use, modification, and         #
+# terms governing use, modification, and redistribution, is contained in     #
+# the file COPYING, which can be found at the root of the source code        #
+# distribution tree.  If you do not have access to this file, you may        #
+# request a copy from help at hdfgroup.org.                                     #
+##############################################################################
+"""
+Implements a viewer frame for compass_model.Array.
+"""
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import wx
+import wx.grid
+from wx.lib.newevent import NewCommandEvent
+
+import os
+import logging
+
+log = logging.getLogger(__name__)
+
+from ..frame import NodeFrame
+from .plot import LinePlotFrame, ContourPlotFrame
+
+
+# Indicates that the slicing selection may have changed.
+# These events are emitted by the SlicerPanel.
+ArraySlicedEvent, EVT_ARRAY_SLICED = NewCommandEvent()
+
+# Menu and button IDs
+ID_VIS_MENU_PLOT = wx.NewId()
+
+
+class GeoArrayFrame(NodeFrame):
+    """
+    Top-level frame displaying objects of type compass_model.Array.
+
+    From top to bottom, has:
+
+    1. Toolbar (see ArrayFrame.init_toolbar)
+    2. SlicerPanel, with controls for changing what's displayed.
+    3. An ArrayGrid, which displays the data in a spreadsheet-like view.
+    """
+
+    def __init__(self, node, pos=None):
+        """ Create a new array viewer to display the node. """
+        NodeFrame.__init__(self, node, size=(800, 400), title=node.display_name, pos=pos)
+
+        self.node = node
+
+        # Update the menu
+        vis_menu = wx.Menu()
+        if self.node.is_plottable():
+            vis_menu.Append(ID_VIS_MENU_PLOT, "Map Data\tCtrl-D")
+            self.add_menu(vis_menu, "Visualize")
+        # Initialize the toolbar
+        self.init_toolbar()
+
+        # The Slicer is the panel with indexing controls
+        self.slicer = SlicerPanel(self, node.shape, node.dtype.fields is not None)
+        # Create the grid array
+        self.grid = ArrayGrid(self, node, self.slicer)
+        # Sizer for slicer and grid
+        gridsizer = wx.BoxSizer(wx.VERTICAL)
+        gridsizer.Add(self.slicer, 0, wx.EXPAND)
+        gridsizer.Add(self.grid, 1, wx.EXPAND)
+        self.view = gridsizer
+
+        self.Bind(EVT_ARRAY_SLICED, self.on_sliced)
+        if self.node.is_plottable():
+            self.Bind(wx.EVT_MENU, self.on_plot, id=ID_VIS_MENU_PLOT)
+
+        # Workaround for wxPython bug (see SlicerPanel.enable_spinctrls)
+        ID_WORKAROUND_TIMER = wx.NewId()
+        self.Bind(wx.EVT_TIMER, self.on_workaround_timer, id=ID_WORKAROUND_TIMER)
+        self.timer = wx.Timer(self, ID_WORKAROUND_TIMER)
+        self.timer.Start(100)
+
+    def init_toolbar(self):
+        """ Set up the toolbar at the top of the window. """
+        t_size = (24, 24)
+        plot_bmp = wx.Bitmap(os.path.join(self.icon_folder, "viz_plot_24.png"), wx.BITMAP_TYPE_ANY)
+
+        self.toolbar = self.CreateToolBar(wx.TB_HORIZONTAL | wx.NO_BORDER | wx.TB_FLAT | wx.TB_TEXT)
+
+        self.toolbar.SetToolBitmapSize(t_size)
+        self.toolbar.AddStretchableSpace()
+        if self.node.is_plottable():
+            self.toolbar.AddLabelTool(ID_VIS_MENU_PLOT, "Map Data", plot_bmp,
+                                      shortHelp="Map geographic data in a popup window",
+                                      longHelp="Map the geographic array data in a popup window")
+        self.toolbar.Realize()
+
+    def on_sliced(self, evt):
+        """ User has chosen to display a different part of the dataset. """
+        self.grid.Refresh()
+
+    def on_plot(self, evt):
+        """ User has chosen to plot the current selection """
+        cols = self.grid.GetSelectedCols()
+        rows = self.grid.GetSelectedRows()
+
+        # Scalar data can't be line-plotted.
+        if len(self.node.shape) == 0:
+            return
+
+        # Columns in the view are selected
+        if len(cols) != 0:
+
+            # The data is compound
+            if self.node.dtype.names is not None:
+                names = [self.grid.GetColLabelValue(x) for x in cols]
+                data = self.node[self.slicer.indices]  # -> 1D compound array
+                data = [data[n] for n in names]
+                f = LinePlotFrame(data, names)
+                f.Show()
+
+            # Plot multiple columns independently
+            else:
+                if len(self.node.shape) == 1:
+                    data = [self.node[self.slicer.indices]]
+                else:
+                    data = [self.node[self.slicer.indices + (c,)] for c in cols]
+
+                names = ["Col %d" % c for c in cols] if len(data) > 1 else None
+
+                f = LinePlotFrame(data, names)
+                f.Show()
+
+
+        # Rows in view are selected
+        elif len(rows) != 0:
+
+            data = [self.node[self.slicer.indices + (slice(None, None, None), r)] for r in rows]
+            names = ["Row %d" % r for r in rows] if len(data) > 1 else None
+
+            f = LinePlotFrame(data, names)
+            f.Show()
+
+
+        # No row or column selection.  Plot everything
+        else:
+
+            data = self.node[self.slicer.indices]
+
+            # The data is compound
+            if self.node.dtype.names is not None:
+                names = [self.grid.GetColLabelValue(x) for x in xrange(self.grid.GetNumberCols())]
+                data = [data[n] for n in names]
+                f = LinePlotFrame(data, names)
+                f.Show()
+
+            # Plot 1D
+            elif len(self.node.shape) == 1:
+                f = LinePlotFrame([data])
+                f.Show()
+
+            # Plot 2D
+            else:
+                f = ContourPlotFrame(data, extent=self.node.extent)
+                f.Show()
+
+    def on_workaround_timer(self, evt):
+        """ See slicer.enable_spinctrls docs """
+        self.timer.Destroy()
+        self.slicer.enable_spinctrls()
+
+
+class SlicerPanel(wx.Panel):
+    """
+    Holds controls for data access.
+
+    Consult the "indices" property, which returns a tuple of indices that
+    prefix the array.  This will be RANK-2 elements long, unless hasfields
+    is true, in which case it will be RANK-1 elements long.
+    """
+
+    @property
+    def indices(self):
+        """ A tuple of integer indices appropriate for slicing.
+
+        Will be RANK-2 elements long, RANK-1 if compound data is in use
+        (hasfields == True).
+        """
+        return tuple([x.GetValue() for x in self.spincontrols])
+
+    def __init__(self, parent, shape, hasfields):
+        """ Create a new slicer panel.
+
+        parent:     The wxPython parent window
+        shape:      Shape of the data to visualize
+        hasfields:  If True, the data is compound and the grid can only
+                    display one axis.  So, we should display an extra spinbox.
+        """
+        wx.Panel.__init__(self, parent)
+
+        self.shape = shape
+        self.hasfields = hasfields
+        self.spincontrols = []
+
+        # Rank of the underlying array
+        rank = len(shape)
+
+        # Rank displayable in the grid.  If fields are present, they occupy
+        # the columns, so the data displayed is actually 1-D.
+        visible_rank = 1 if hasfields else 2
+
+        sizer = wx.BoxSizer(wx.HORIZONTAL)  # Will arrange the SpinCtrls
+
+        if rank > visible_rank:
+            infotext = wx.StaticText(self, wx.ID_ANY, "Array Indexing: ")
+            sizer.Add(infotext, 0, flag=wx.EXPAND | wx.ALL, border=10)
+
+            for idx in xrange(rank - visible_rank):
+                sc = wx.SpinCtrl(self, max=shape[idx] - 1, value="0", min=0)
+                sizer.Add(sc, 0, flag=wx.EXPAND | wx.ALL, border=10)
+                sc.Disable()
+                self.spincontrols.append(sc)
+
+        self.SetSizer(sizer)
+
+        self.Bind(wx.EVT_SPINCTRL, self.on_spin)
+
+    def enable_spinctrls(self):
+        """ Unlock the spin controls.
+
+        Because of a bug in wxPython on Mac, by default the first spin control
+        has bizarre contents (and control focus) when the panel starts up.
+        Call this after a short delay (e.g. 100 ms) to enable indexing.
+        """
+        for sc in self.spincontrols:
+            sc.Enable()
+
+    def on_spin(self, evt):
+        """ Spinbox value changed; notify parent to refresh the grid. """
+        wx.PostEvent(self, ArraySlicedEvent(self.GetId()))
+
+
+class ArrayGrid(wx.grid.Grid):
+    """
+    Grid class to display the Array.
+
+    Cell contents and appearance are handled by the table model in ArrayTable.
+    """
+
+    def __init__(self, parent, node, slicer):
+        wx.grid.Grid.__init__(self, parent)
+        table = ArrayTable(node, slicer)
+        self.SetTable(table, True)
+
+        # Column selection is always allowed
+        selmode = wx.grid.Grid.wxGridSelectColumns
+
+        # Row selection is forbidden for compound types, and for
+        # scalar/1-D datasets
+        if node.dtype.names is None and len(node.shape) > 1:
+            selmode |= wx.grid.Grid.wxGridSelectRows
+
+        self.SetSelectionMode(selmode)
+
+
+class LRUTileCache(object):
+    """
+        Simple tile-based LRU cache which goes between the Grid and
+        the Array object.  Caches tiles along the last 1 or 2 dimensions
+        of a dataset.
+
+        Access is via __getitem__.  Because this class exists specifically
+        to support point-based callbacks for the Grid, arguments may
+        only be indices, not slices.
+    """
+
+    TILESIZE = 100  # Tiles will have shape (100,) or (100, 100)
+    MAXTILES = 50  # Max number of tiles to retain in the cache
+
+    def __init__(self, arr):
+        """ *arr* is anything implementing compass_model.Array """
+        import collections
+        self.cache = collections.OrderedDict()
+        self.arr = arr
+
+    def __getitem__(self, args):
+        """ Restricted to an index or tuple of indices. """
+
+        if not isinstance(args, tuple):
+            args = (args,)
+
+        # Split off the last 1 or 2 dimensions
+        coarse_position, fine_position = args[0:-2], args[-2:]
+
+        def clip(x):
+            """ Round down to nearest TILESIZE; takes e.g. 181 -> 100 """
+            return (x // self.TILESIZE) * self.TILESIZE
+
+        # Tuple with index of tile corner
+        tile_key = coarse_position + tuple(clip(x) for x in fine_position)
+
+        # Slice which will be applied to dataset to retrieve tile
+        tile_slice = coarse_position + tuple(slice(clip(x), clip(x) + self.TILESIZE) for x in fine_position)
+
+        # Index applied to tile to retrieve the desired data point
+        tile_data_index = tuple(x % self.TILESIZE for x in fine_position)
+
+        # Case 1: Add tile to cache, ejecting oldest tile if needed
+        if not tile_key in self.cache:
+
+            if len(self.cache) >= self.MAXTILES:
+                self.cache.popitem(last=False)
+
+            tile = self.arr[tile_slice]
+            self.cache[tile_key] = tile
+
+        # Case 2: Mark the tile as recently accessed
+        else:
+            tile = self.cache.pop(tile_key)
+            self.cache[tile_key] = tile
+
+        return tile[tile_data_index]
+
+
+class ArrayTable(wx.grid.PyGridTableBase):
+    """
+    "Table" class which provides data and metadata for the grid to display.
+
+    The methods defined here define the contents of the table, as well as
+    the number of rows, columns and their values.
+    """
+
+    def __init__(self, node, slicer):
+        """ Create a new Table instance for use with a grid control.
+
+        node:     An compass_model.Array implementation instance.
+        slicer:   An instance of SlicerPanel, so we can see what indices the
+                  user has requested.
+        """
+        wx.grid.PyGridTableBase.__init__(self)
+
+        self.node = node
+        self.slicer = slicer
+
+        self.rank = len(node.shape)
+        self.names = node.dtype.names
+
+        self.cache = LRUTileCache(self.node)
+
+    def GetNumberRows(self):
+        """ Callback for number of rows displayed by the grid control """
+        if self.rank == 0:
+            return 1
+        return self.node.shape[-1]
+
+    def GetNumberCols(self):
+        """ Callback for number of columns displayed by the grid control.
+
+        Note that if compound data is in use, columns display the field names.
+        """
+        if self.names is not None:
+            return len(self.names)
+        if self.rank < 2:
+            return 1
+        return self.node.shape[-2]
+
+    def GetValue(self, row, col):
+        """ Callback which provides data to the Grid.
+
+        row, col:   Integers giving row and column position (0-based).
+        """
+        # Scalar case
+        if self.rank == 0:
+            data = self.node[()]
+            if self.names is None:
+                return data
+            return data[col]
+
+        # 1D case
+        if self.rank == 1:
+            data = self.cache[row]
+            if self.names is None:
+                return data
+            return data[self.names[col]]
+
+        # ND case.  Watch out for compound mode!
+        if self.names is None:
+            args = self.slicer.indices + (col, row)
+        else:
+            args = self.slicer.indices + (row,)
+
+        data = self.cache[args]
+        if self.names is None:
+            return data
+        return data[self.names[col]]
+
+    def GetRowLabelValue(self, row):
+        """ Callback for row labels.
+
+        Row number is used unless the data is scalar.
+        """
+        if self.rank == 0:
+            return "Value"
+        return str(row)
+
+    def GetColLabelValue(self, col):
+        """ Callback for column labels.
+
+        Column number is used, except for scalar or 1D data, or if we're
+        displaying field names in the columns.
+        """
+        if self.names is not None:
+            return self.names[col]
+        if self.rank == 0 or self.rank == 1:
+            return "Value"
+        return str(col)
diff --git a/hdf_compass/compass_viewer/geo_array/plot.py b/hdf_compass/compass_viewer/geo_array/plot.py
new file mode 100644
index 0000000..29ca013
--- /dev/null
+++ b/hdf_compass/compass_viewer/geo_array/plot.py
@@ -0,0 +1,207 @@
+##############################################################################
+# Copyright by The HDF Group.                                                #
+# All rights reserved.                                                       #
+#                                                                            #
+# This file is part of the HDF Compass Viewer. The full HDF Compass          #
+# copyright notice, including terms governing use, modification, and         #
+# terms governing use, modification, and redistribution, is contained in     #
+# the file COPYING, which can be found at the root of the source code        #
+# distribution tree.  If you do not have access to this file, you may        #
+# request a copy from help at hdfgroup.org.                                     #
+##############################################################################
+
+"""
+Matplotlib window with toolbar.
+"""
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import numpy as np
+import wx
+import cartopy.crs as ccrs
+from cartopy.mpl.gridliner import LONGITUDE_FORMATTER, LATITUDE_FORMATTER
+import matplotlib.pyplot as plt
+from matplotlib.figure import Figure
+from matplotlib.backends.backend_wxagg import FigureCanvasWxAgg as FigCanvas
+from matplotlib.backends.backend_wx import NavigationToolbar2Wx as NavigationToolbar
+
+import logging
+log = logging.getLogger(__name__)
+
+from ..frame import BaseFrame
+
+ID_VIEW_CMAP_JET = wx.NewId()  # default
+ID_VIEW_CMAP_BONE = wx.NewId()
+ID_VIEW_CMAP_GIST_EARTH = wx.NewId()
+ID_VIEW_CMAP_OCEAN = wx.NewId()
+ID_VIEW_CMAP_RAINBOW = wx.NewId()
+ID_VIEW_CMAP_RDYLGN = wx.NewId()
+ID_VIEW_CMAP_WINTER = wx.NewId()
+
+
+class PlotFrame(BaseFrame):
+    """ Base class for Matplotlib plot windows.
+
+    Override draw_figure() to plot your figure on the provided axes.
+    """
+
+    def __init__(self, data, title="a title"):
+        """ Create a new Matplotlib plotting window for a 1D line plot """
+
+        log.debug(self.__class__.__name__)
+        BaseFrame.__init__(self, id=wx.ID_ANY, title=title, size=(800, 400))
+
+        self.data = data
+
+        self.panel = wx.Panel(self)
+
+        self.dpi = 100
+        self.fig = Figure((6.0, 4.0), dpi=self.dpi)
+        self.canvas = FigCanvas(self.panel, -1, self.fig)
+
+        self.axes = self.fig.add_subplot(111, projection=ccrs.PlateCarree())
+        self.toolbar = NavigationToolbar(self.canvas)
+
+        self.vbox = wx.BoxSizer(wx.VERTICAL)
+        self.vbox.Add(self.canvas, 1, wx.LEFT | wx.TOP | wx.GROW)
+        self.vbox.Add(self.toolbar, 0, wx.EXPAND)
+
+        self.panel.SetSizer(self.vbox)
+        self.vbox.Fit(self)
+
+        self.draw_figure()
+
+    def draw_figure(self):
+        raise NotImplementedError
+
+
+class LinePlotFrame(PlotFrame):
+    def __init__(self, data, names=None, title="Line Plot"):
+        self.names = names
+        PlotFrame.__init__(self, data, title)
+
+    def draw_figure(self):
+
+        lines = [self.axes.plot(d)[0] for d in self.data]
+        if self.names is not None:
+            for n in self.names:
+                self.axes.legend(tuple(lines), tuple(self.names))
+
+
+class ContourPlotFrame(PlotFrame):
+    def __init__(self, data, extent, names=None, title="Contour Map"):
+        self.geo_extent = extent
+        log.debug("Extent: %f, %f, %f, %f" % self.geo_extent)
+        # need to be set before calling the parent (need for plotting)
+        self.colormap = "jet"
+        self.cb = None  # matplotlib color-bar
+        self.xx = None
+        self.yy = None
+
+        PlotFrame.__init__(self, data, title)
+
+        self.cmap_menu = wx.Menu()
+        self.cmap_menu.Append(ID_VIEW_CMAP_JET, "Jet", kind=wx.ITEM_RADIO)
+        self.cmap_menu.Append(ID_VIEW_CMAP_BONE, "Bone", kind=wx.ITEM_RADIO)
+        self.cmap_menu.Append(ID_VIEW_CMAP_GIST_EARTH, "Gist Earth", kind=wx.ITEM_RADIO)
+        self.cmap_menu.Append(ID_VIEW_CMAP_OCEAN, "Ocean", kind=wx.ITEM_RADIO)
+        self.cmap_menu.Append(ID_VIEW_CMAP_RAINBOW, "Rainbow", kind=wx.ITEM_RADIO)
+        self.cmap_menu.Append(ID_VIEW_CMAP_RDYLGN, "Red-Yellow-Green", kind=wx.ITEM_RADIO)
+        self.cmap_menu.Append(ID_VIEW_CMAP_WINTER, "Winter", kind=wx.ITEM_RADIO)
+        self.add_menu(self.cmap_menu, "Colormap")
+
+        self.Bind(wx.EVT_MENU, self.on_cmap_jet, id=ID_VIEW_CMAP_JET)
+        self.Bind(wx.EVT_MENU, self.on_cmap_bone, id=ID_VIEW_CMAP_BONE)
+        self.Bind(wx.EVT_MENU, self.on_cmap_gist_earth, id=ID_VIEW_CMAP_GIST_EARTH)
+        self.Bind(wx.EVT_MENU, self.on_cmap_ocean, id=ID_VIEW_CMAP_OCEAN)
+        self.Bind(wx.EVT_MENU, self.on_cmap_rainbow, id=ID_VIEW_CMAP_RAINBOW)
+        self.Bind(wx.EVT_MENU, self.on_cmap_rdylgn, id=ID_VIEW_CMAP_RDYLGN)
+        self.Bind(wx.EVT_MENU, self.on_cmap_winter, id=ID_VIEW_CMAP_WINTER)
+
+        self.status_bar = wx.StatusBar(self, -1)
+        self.status_bar.SetFieldsCount(2)
+        self.SetStatusBar(self.status_bar)
+
+        self.canvas.mpl_connect('motion_notify_event', self.update_status_bar)
+        self.canvas.Bind(wx.EVT_ENTER_WINDOW, self.change_cursor)
+
+    def on_cmap_jet(self, evt):
+        log.debug("cmap: jet")
+        self.colormap = "jet"
+        self._refresh_plot()
+
+    def on_cmap_bone(self, evt):
+        log.debug("cmap: bone")
+        self.colormap = "bone"
+        self._refresh_plot()
+
+    def on_cmap_gist_earth(self, evt):
+        log.debug("cmap: gist_earth")
+        self.colormap = "gist_earth"
+        self._refresh_plot()
+
+    def on_cmap_ocean(self, evt):
+        log.debug("cmap: ocean")
+        self.colormap = "ocean"
+        self._refresh_plot()
+
+    def on_cmap_rainbow(self, evt):
+        log.debug("cmap: rainbow")
+        self.colormap = "rainbow"
+        self._refresh_plot()
+
+    def on_cmap_rdylgn(self, evt):
+        log.debug("cmap: RdYlGn")
+        self.colormap = "RdYlGn"
+        self._refresh_plot()
+
+    def on_cmap_winter(self, evt):
+        log.debug("cmap: winter")
+        self.colormap = "winter"
+        self._refresh_plot()
+
+    def _refresh_plot(self):
+        self.draw_figure()
+        self.canvas.draw()
+
+    def draw_figure(self):
+        max_elements = 500  # don't attempt plot more than 500x500 elements
+        rows = self.data.shape[0]
+        cols = self.data.shape[1]
+        row_stride = rows // max_elements + 1
+        col_stride = cols // max_elements + 1
+        data = self.data[::row_stride, ::col_stride]
+        self.xx = np.linspace(self.geo_extent[0], self.geo_extent[1], data.shape[1])
+        self.yy = np.linspace(self.geo_extent[2], self.geo_extent[3], data.shape[0])
+        img = self.axes.contourf(self.xx, self.yy, data, 25, cmap=plt.cm.get_cmap(self.colormap),
+                                 transform=ccrs.PlateCarree())
+        self.axes.coastlines(resolution='50m', color='gray', linewidth=1)
+        # add gridlines with labels only on the left and on the bottom
+        grl = self.axes.gridlines(crs=ccrs.PlateCarree(), color='gray', draw_labels=True)
+        grl.xformatter = LONGITUDE_FORMATTER
+        grl.yformatter = LATITUDE_FORMATTER
+        grl.xlabel_style = {'size': 8}
+        grl.ylabel_style = {'size': 8}
+        grl.ylabels_right = False
+        grl.xlabels_top = False
+
+        if self.cb:
+            self.cb.on_mappable_changed(img)
+        else:
+            self.cb = plt.colorbar(img, ax=self.axes)
+        self.cb.ax.tick_params(labelsize=8)
+
+    def change_cursor(self, event):
+        self.canvas.SetCursor(wx.StockCursor(wx.CURSOR_CROSS))
+
+    @staticmethod
+    def _find_nearest(arr, value):
+        """ Helper function to find the nearest value in an array """
+        return (np.abs(arr - value)).argmin()
+
+    def update_status_bar(self, event):
+        msg = str()
+        if event.inaxes:
+            x, y = event.xdata, event.ydata
+            z = self.data[self._find_nearest(self.yy, y), self._find_nearest(self.xx, x)]
+            msg = "x= %f, y= %f, z= %f" % (x, y, z)
+        self.status_bar.SetStatusText(msg, 1)
diff --git a/hdf_compass/compass_model/__init__.py b/hdf_compass/compass_viewer/geo_surface/__init__.py
similarity index 87%
copy from hdf_compass/compass_model/__init__.py
copy to hdf_compass/compass_viewer/geo_surface/__init__.py
index 0853f0a..0898d00 100644
--- a/hdf_compass/compass_model/__init__.py
+++ b/hdf_compass/compass_viewer/geo_surface/__init__.py
@@ -11,8 +11,8 @@
 ##############################################################################
 from __future__ import absolute_import, division, print_function, unicode_literals
 
-from .model import get_stores, push, Store, Node, Container, KeyValue, Array, Text, Xml, Image, Unknown
+from .frame import GeoSurfaceFrame
 
 import logging
 log = logging.getLogger(__name__)
-log.addHandler(logging.NullHandler())
+log.addHandler(logging.NullHandler())
\ No newline at end of file
diff --git a/hdf_compass/compass_viewer/geo_surface/frame.py b/hdf_compass/compass_viewer/geo_surface/frame.py
new file mode 100644
index 0000000..02eabe5
--- /dev/null
+++ b/hdf_compass/compass_viewer/geo_surface/frame.py
@@ -0,0 +1,416 @@
+##############################################################################
+# Copyright by The HDF Group.                                                #
+# All rights reserved.                                                       #
+#                                                                            #
+# This file is part of the HDF Compass Viewer. The full HDF Compass          #
+# copyright notice, including terms governing use, modification, and         #
+# terms governing use, modification, and redistribution, is contained in     #
+# the file COPYING, which can be found at the root of the source code        #
+# distribution tree.  If you do not have access to this file, you may        #
+# request a copy from help at hdfgroup.org.                                     #
+##############################################################################
+"""
+Implements a viewer frame for compass_model.Array.
+"""
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import wx
+import wx.grid
+from wx.lib.newevent import NewCommandEvent
+
+import os
+import logging
+
+log = logging.getLogger(__name__)
+
+from ..frame import NodeFrame
+from .plot import LinePlotFrame, ContourPlotFrame
+
+
+# Indicates that the slicing selection may have changed.
+# These events are emitted by the SlicerPanel.
+ArraySlicedEvent, EVT_ARRAY_SLICED = NewCommandEvent()
+
+# Menu and button IDs
+ID_VIS_MENU_PLOT = wx.NewId()
+
+
+class GeoSurfaceFrame(NodeFrame):
+    """
+    Top-level frame displaying objects of type compass_model.Array.
+
+    From top to bottom, has:
+
+    1. Toolbar (see ArrayFrame.init_toolbar)
+    2. SlicerPanel, with controls for changing what's displayed.
+    3. An ArrayGrid, which displays the data in a spreadsheet-like view.
+    """
+
+    def __init__(self, node, pos=None):
+        """ Create a new array viewer to display the node. """
+        NodeFrame.__init__(self, node, size=(800, 400), title=node.display_name, pos=pos)
+
+        self.node = node
+
+        # Update the menu
+        vis_menu = wx.Menu()
+        if self.node.is_plottable():
+            vis_menu.Append(ID_VIS_MENU_PLOT, "Map Surface\tCtrl-D")
+            self.add_menu(vis_menu, "Visualize")
+        # Initialize the toolbar
+        self.init_toolbar()
+
+        # The Slicer is the panel with indexing controls
+        self.slicer = SlicerPanel(self, node.shape, node.dtype.fields is not None)
+        # Create the grid array
+        self.grid = ArrayGrid(self, node, self.slicer)
+        # Sizer for slicer and grid
+        gridsizer = wx.BoxSizer(wx.VERTICAL)
+        gridsizer.Add(self.slicer, 0, wx.EXPAND)
+        gridsizer.Add(self.grid, 1, wx.EXPAND)
+        self.view = gridsizer
+
+        self.Bind(EVT_ARRAY_SLICED, self.on_sliced)
+        if self.node.is_plottable():
+            self.Bind(wx.EVT_MENU, self.on_plot, id=ID_VIS_MENU_PLOT)
+
+        # Workaround for wxPython bug (see SlicerPanel.enable_spinctrls)
+        ID_WORKAROUND_TIMER = wx.NewId()
+        self.Bind(wx.EVT_TIMER, self.on_workaround_timer, id=ID_WORKAROUND_TIMER)
+        self.timer = wx.Timer(self, ID_WORKAROUND_TIMER)
+        self.timer.Start(100)
+
+    def init_toolbar(self):
+        """ Set up the toolbar at the top of the window. """
+        t_size = (24, 24)
+        plot_bmp = wx.Bitmap(os.path.join(self.icon_folder, "viz_plot_24.png"), wx.BITMAP_TYPE_ANY)
+
+        self.toolbar = self.CreateToolBar(wx.TB_HORIZONTAL | wx.NO_BORDER | wx.TB_FLAT | wx.TB_TEXT)
+
+        self.toolbar.SetToolBitmapSize(t_size)
+        self.toolbar.AddStretchableSpace()
+        if self.node.is_plottable():
+            self.toolbar.AddLabelTool(ID_VIS_MENU_PLOT, "Map Surface", plot_bmp,
+                                      shortHelp="Map geographic surface in a popup window",
+                                      longHelp="Map the geographic surface array in a popup window")
+        self.toolbar.Realize()
+
+    def on_sliced(self, evt):
+        """ User has chosen to display a different part of the dataset. """
+        self.grid.Refresh()
+
+    def on_plot(self, evt):
+        """ User has chosen to plot the current selection """
+        cols = self.grid.GetSelectedCols()
+        rows = self.grid.GetSelectedRows()
+
+        # Scalar data can't be line-plotted.
+        if len(self.node.shape) == 0:
+            return
+
+        # Columns in the view are selected
+        if len(cols) != 0:
+
+            # The data is compound
+            if self.node.dtype.names is not None:
+                names = [self.grid.GetColLabelValue(x) for x in cols]
+                data = self.node[self.slicer.indices]  # -> 1D compound array
+                data = [data[n] for n in names]
+                f = LinePlotFrame(data, names)
+                f.Show()
+
+            # Plot multiple columns independently
+            else:
+                if len(self.node.shape) == 1:
+                    data = [self.node[self.slicer.indices]]
+                else:
+                    data = [self.node[self.slicer.indices + (c,)] for c in cols]
+
+                names = ["Col %d" % c for c in cols] if len(data) > 1 else None
+
+                f = LinePlotFrame(data, names)
+                f.Show()
+
+
+        # Rows in view are selected
+        elif len(rows) != 0:
+
+            data = [self.node[self.slicer.indices + (slice(None, None, None), r)] for r in rows]
+            names = ["Row %d" % r for r in rows] if len(data) > 1 else None
+
+            f = LinePlotFrame(data, names)
+            f.Show()
+
+
+        # No row or column selection.  Plot everything
+        else:
+
+            data = self.node[self.slicer.indices]
+
+            # The data is compound
+            if self.node.dtype.names is not None:
+                names = [self.grid.GetColLabelValue(x) for x in xrange(self.grid.GetNumberCols())]
+                data = [data[n] for n in names]
+                f = LinePlotFrame(data, names)
+                f.Show()
+
+            # Plot 1D
+            elif len(self.node.shape) == 1:
+                f = LinePlotFrame([data])
+                f.Show()
+
+            # Plot 2D
+            else:
+                f = ContourPlotFrame(data, extent=self.node.extent)
+                f.Show()
+
+    def on_workaround_timer(self, evt):
+        """ See slicer.enable_spinctrls docs """
+        self.timer.Destroy()
+        self.slicer.enable_spinctrls()
+
+
+class SlicerPanel(wx.Panel):
+    """
+    Holds controls for data access.
+
+    Consult the "indices" property, which returns a tuple of indices that
+    prefix the array.  This will be RANK-2 elements long, unless hasfields
+    is true, in which case it will be RANK-1 elements long.
+    """
+
+    @property
+    def indices(self):
+        """ A tuple of integer indices appropriate for slicing.
+
+        Will be RANK-2 elements long, RANK-1 if compound data is in use
+        (hasfields == True).
+        """
+        return tuple([x.GetValue() for x in self.spincontrols])
+
+    def __init__(self, parent, shape, hasfields):
+        """ Create a new slicer panel.
+
+        parent:     The wxPython parent window
+        shape:      Shape of the data to visualize
+        hasfields:  If True, the data is compound and the grid can only
+                    display one axis.  So, we should display an extra spinbox.
+        """
+        wx.Panel.__init__(self, parent)
+
+        self.shape = shape
+        self.hasfields = hasfields
+        self.spincontrols = []
+
+        # Rank of the underlying array
+        rank = len(shape)
+
+        # Rank displayable in the grid.  If fields are present, they occupy
+        # the columns, so the data displayed is actually 1-D.
+        visible_rank = 1 if hasfields else 2
+
+        sizer = wx.BoxSizer(wx.HORIZONTAL)  # Will arrange the SpinCtrls
+
+        if rank > visible_rank:
+            infotext = wx.StaticText(self, wx.ID_ANY, "Array Indexing: ")
+            sizer.Add(infotext, 0, flag=wx.EXPAND | wx.ALL, border=10)
+
+            for idx in xrange(rank - visible_rank):
+                sc = wx.SpinCtrl(self, max=shape[idx] - 1, value="0", min=0)
+                sizer.Add(sc, 0, flag=wx.EXPAND | wx.ALL, border=10)
+                sc.Disable()
+                self.spincontrols.append(sc)
+
+        self.SetSizer(sizer)
+
+        self.Bind(wx.EVT_SPINCTRL, self.on_spin)
+
+    def enable_spinctrls(self):
+        """ Unlock the spin controls.
+
+        Because of a bug in wxPython on Mac, by default the first spin control
+        has bizarre contents (and control focus) when the panel starts up.
+        Call this after a short delay (e.g. 100 ms) to enable indexing.
+        """
+        for sc in self.spincontrols:
+            sc.Enable()
+
+    def on_spin(self, evt):
+        """ Spinbox value changed; notify parent to refresh the grid. """
+        wx.PostEvent(self, ArraySlicedEvent(self.GetId()))
+
+
+class ArrayGrid(wx.grid.Grid):
+    """
+    Grid class to display the Array.
+
+    Cell contents and appearance are handled by the table model in ArrayTable.
+    """
+
+    def __init__(self, parent, node, slicer):
+        wx.grid.Grid.__init__(self, parent)
+        table = ArrayTable(node, slicer)
+        self.SetTable(table, True)
+
+        # Column selection is always allowed
+        selmode = wx.grid.Grid.wxGridSelectColumns
+
+        # Row selection is forbidden for compound types, and for
+        # scalar/1-D datasets
+        if node.dtype.names is None and len(node.shape) > 1:
+            selmode |= wx.grid.Grid.wxGridSelectRows
+
+        self.SetSelectionMode(selmode)
+
+
+class LRUTileCache(object):
+    """
+        Simple tile-based LRU cache which goes between the Grid and
+        the Array object.  Caches tiles along the last 1 or 2 dimensions
+        of a dataset.
+
+        Access is via __getitem__.  Because this class exists specifically
+        to support point-based callbacks for the Grid, arguments may
+        only be indices, not slices.
+    """
+
+    TILESIZE = 100  # Tiles will have shape (100,) or (100, 100)
+    MAXTILES = 50  # Max number of tiles to retain in the cache
+
+    def __init__(self, arr):
+        """ *arr* is anything implementing compass_model.Array """
+        import collections
+        self.cache = collections.OrderedDict()
+        self.arr = arr
+
+    def __getitem__(self, args):
+        """ Restricted to an index or tuple of indices. """
+
+        if not isinstance(args, tuple):
+            args = (args,)
+
+        # Split off the last 1 or 2 dimensions
+        coarse_position, fine_position = args[0:-2], args[-2:]
+
+        def clip(x):
+            """ Round down to nearest TILESIZE; takes e.g. 181 -> 100 """
+            return (x // self.TILESIZE) * self.TILESIZE
+
+        # Tuple with index of tile corner
+        tile_key = coarse_position + tuple(clip(x) for x in fine_position)
+
+        # Slice which will be applied to dataset to retrieve tile
+        tile_slice = coarse_position + tuple(slice(clip(x), clip(x) + self.TILESIZE) for x in fine_position)
+
+        # Index applied to tile to retrieve the desired data point
+        tile_data_index = tuple(x % self.TILESIZE for x in fine_position)
+
+        # Case 1: Add tile to cache, ejecting oldest tile if needed
+        if not tile_key in self.cache:
+
+            if len(self.cache) >= self.MAXTILES:
+                self.cache.popitem(last=False)
+
+            tile = self.arr[tile_slice]
+            self.cache[tile_key] = tile
+
+        # Case 2: Mark the tile as recently accessed
+        else:
+            tile = self.cache.pop(tile_key)
+            self.cache[tile_key] = tile
+
+        return tile[tile_data_index]
+
+
+class ArrayTable(wx.grid.PyGridTableBase):
+    """
+    "Table" class which provides data and metadata for the grid to display.
+
+    The methods defined here define the contents of the table, as well as
+    the number of rows, columns and their values.
+    """
+
+    def __init__(self, node, slicer):
+        """ Create a new Table instance for use with a grid control.
+
+        node:     An compass_model.Array implementation instance.
+        slicer:   An instance of SlicerPanel, so we can see what indices the
+                  user has requested.
+        """
+        wx.grid.PyGridTableBase.__init__(self)
+
+        self.node = node
+        self.slicer = slicer
+
+        self.rank = len(node.shape)
+        self.names = node.dtype.names
+
+        self.cache = LRUTileCache(self.node)
+
+    def GetNumberRows(self):
+        """ Callback for number of rows displayed by the grid control """
+        if self.rank == 0:
+            return 1
+        return self.node.shape[-1]
+
+    def GetNumberCols(self):
+        """ Callback for number of columns displayed by the grid control.
+
+        Note that if compound data is in use, columns display the field names.
+        """
+        if self.names is not None:
+            return len(self.names)
+        if self.rank < 2:
+            return 1
+        return self.node.shape[-2]
+
+    def GetValue(self, row, col):
+        """ Callback which provides data to the Grid.
+
+        row, col:   Integers giving row and column position (0-based).
+        """
+        # Scalar case
+        if self.rank == 0:
+            data = self.node[()]
+            if self.names is None:
+                return data
+            return data[col]
+
+        # 1D case
+        if self.rank == 1:
+            data = self.cache[row]
+            if self.names is None:
+                return data
+            return data[self.names[col]]
+
+        # ND case.  Watch out for compound mode!
+        if self.names is None:
+            args = self.slicer.indices + (col, row)
+        else:
+            args = self.slicer.indices + (row,)
+
+        data = self.cache[args]
+        if self.names is None:
+            return data
+        return data[self.names[col]]
+
+    def GetRowLabelValue(self, row):
+        """ Callback for row labels.
+
+        Row number is used unless the data is scalar.
+        """
+        if self.rank == 0:
+            return "Value"
+        return str(row)
+
+    def GetColLabelValue(self, col):
+        """ Callback for column labels.
+
+        Column number is used, except for scalar or 1D data, or if we're
+        displaying field names in the columns.
+        """
+        if self.names is not None:
+            return self.names[col]
+        if self.rank == 0 or self.rank == 1:
+            return "Value"
+        return str(col)
diff --git a/hdf_compass/compass_viewer/geo_surface/plot.py b/hdf_compass/compass_viewer/geo_surface/plot.py
new file mode 100644
index 0000000..17e7a91
--- /dev/null
+++ b/hdf_compass/compass_viewer/geo_surface/plot.py
@@ -0,0 +1,213 @@
+##############################################################################
+# Copyright by The HDF Group.                                                #
+# All rights reserved.                                                       #
+#                                                                            #
+# This file is part of the HDF Compass Viewer. The full HDF Compass          #
+# copyright notice, including terms governing use, modification, and         #
+# terms governing use, modification, and redistribution, is contained in     #
+# the file COPYING, which can be found at the root of the source code        #
+# distribution tree.  If you do not have access to this file, you may        #
+# request a copy from help at hdfgroup.org.                                     #
+##############################################################################
+
+"""
+Matplotlib window with toolbar.
+"""
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import numpy as np
+import wx
+import cartopy.crs as ccrs
+from cartopy.mpl.gridliner import LONGITUDE_FORMATTER, LATITUDE_FORMATTER
+import matplotlib.pyplot as plt
+from matplotlib.figure import Figure
+from matplotlib.backends.backend_wxagg import FigureCanvasWxAgg as FigCanvas
+from matplotlib.backends.backend_wx import NavigationToolbar2Wx as NavigationToolbar
+from matplotlib.colors import LinearSegmentedColormap
+from matplotlib import cm
+from matplotlib.colors import LightSource
+
+import logging
+log = logging.getLogger(__name__)
+
+from ..frame import BaseFrame
+
+np.seterr(divide='ignore', invalid='ignore')
+
+ID_VIEW_CMAP_BAG = wx.NewId()  # default
+ID_VIEW_CMAP_JET = wx.NewId()
+ID_VIEW_CMAP_BONE = wx.NewId()
+ID_VIEW_CMAP_GIST_EARTH = wx.NewId()
+ID_VIEW_CMAP_OCEAN = wx.NewId()
+ID_VIEW_CMAP_RAINBOW = wx.NewId()
+ID_VIEW_CMAP_RDYLGN = wx.NewId()
+ID_VIEW_CMAP_WINTER = wx.NewId()
+
+
+class PlotFrame(BaseFrame):
+    """ Base class for Matplotlib plot windows.
+
+    Override draw_figure() to plot your figure on the provided axes.
+    """
+
+    def __init__(self, data, title="a title"):
+        """ Create a new Matplotlib plotting window for a 1D line plot """
+
+        log.debug(self.__class__.__name__)
+        BaseFrame.__init__(self, id=wx.ID_ANY, title=title, size=(800, 400))
+
+        self.data = data
+
+        self.panel = wx.Panel(self)
+
+        self.dpi = 100
+        self.fig = Figure((6.0, 4.0), dpi=self.dpi)
+        self.canvas = FigCanvas(self.panel, -1, self.fig)
+
+        self.axes = self.fig.add_subplot(111, projection=ccrs.PlateCarree())
+        self.toolbar = NavigationToolbar(self.canvas)
+
+        self.vbox = wx.BoxSizer(wx.VERTICAL)
+        self.vbox.Add(self.canvas, 1, wx.LEFT | wx.TOP | wx.GROW)
+        self.vbox.Add(self.toolbar, 0, wx.EXPAND)
+
+        self.panel.SetSizer(self.vbox)
+        self.vbox.Fit(self)
+
+        self.draw_figure()
+
+    def draw_figure(self):
+        raise NotImplementedError
+
+
+class LinePlotFrame(PlotFrame):
+    def __init__(self, data, names=None, title="Line Plot"):
+        self.names = names
+        PlotFrame.__init__(self, data, title)
+
+    def draw_figure(self):
+
+        lines = [self.axes.plot(d)[0] for d in self.data]
+        if self.names is not None:
+            for n in self.names:
+                self.axes.legend(tuple(lines), tuple(self.names))
+
+
+class ContourPlotFrame(PlotFrame):
+    def __init__(self, data, extent, names=None, title="Surface Map"):
+        self.geo_extent = extent
+        log.debug("Extent: %f, %f, %f, %f" % self.geo_extent)
+        # need to be set before calling the parent (need for plotting)
+        self.colormap = LinearSegmentedColormap.from_list("BAG",
+                                                          ["#63006c", "#2b4ef4", "#2f73ff", "#4b8af4", "#bee2bf"])
+        # register a new colormap
+        cm.register_cmap(cmap=self.colormap)
+        self.cb = None  # matplotlib color-bar
+        self.xx = None
+        self.yy = None
+
+        PlotFrame.__init__(self, data, title)
+
+        self.cmap_menu = wx.Menu()
+        self.cmap_menu.Append(ID_VIEW_CMAP_BAG, "BAG", kind=wx.ITEM_RADIO)
+        self.cmap_menu.Append(ID_VIEW_CMAP_JET, "Jet", kind=wx.ITEM_RADIO)
+        self.cmap_menu.Append(ID_VIEW_CMAP_BONE, "Bone", kind=wx.ITEM_RADIO)
+        self.cmap_menu.Append(ID_VIEW_CMAP_GIST_EARTH, "Gist Earth", kind=wx.ITEM_RADIO)
+        self.cmap_menu.Append(ID_VIEW_CMAP_OCEAN, "Ocean", kind=wx.ITEM_RADIO)
+        self.cmap_menu.Append(ID_VIEW_CMAP_RAINBOW, "Rainbow", kind=wx.ITEM_RADIO)
+        self.cmap_menu.Append(ID_VIEW_CMAP_RDYLGN, "Red-Yellow-Green", kind=wx.ITEM_RADIO)
+        self.cmap_menu.Append(ID_VIEW_CMAP_WINTER, "Winter", kind=wx.ITEM_RADIO)
+        self.add_menu(self.cmap_menu, "Colormap")
+
+        self.Bind(wx.EVT_MENU, self.on_cmap_bag, id=ID_VIEW_CMAP_BAG)
+        self.Bind(wx.EVT_MENU, self.on_cmap_jet, id=ID_VIEW_CMAP_JET)
+        self.Bind(wx.EVT_MENU, self.on_cmap_bone, id=ID_VIEW_CMAP_BONE)
+        self.Bind(wx.EVT_MENU, self.on_cmap_gist_earth, id=ID_VIEW_CMAP_GIST_EARTH)
+        self.Bind(wx.EVT_MENU, self.on_cmap_ocean, id=ID_VIEW_CMAP_OCEAN)
+        self.Bind(wx.EVT_MENU, self.on_cmap_rainbow, id=ID_VIEW_CMAP_RAINBOW)
+        self.Bind(wx.EVT_MENU, self.on_cmap_rdylgn, id=ID_VIEW_CMAP_RDYLGN)
+        self.Bind(wx.EVT_MENU, self.on_cmap_winter, id=ID_VIEW_CMAP_WINTER)
+
+        self.status_bar = wx.StatusBar(self, -1)
+        self.status_bar.SetFieldsCount(2)
+        self.SetStatusBar(self.status_bar)
+
+        self.canvas.mpl_connect('motion_notify_event', self.update_status_bar)
+        self.canvas.Bind(wx.EVT_ENTER_WINDOW, self.change_cursor)
+
+    def on_cmap_bag(self, evt):
+        log.debug("cmap: bag")
+        self.colormap = cm.get_cmap("BAG")
+        self._refresh_plot()
+
+    def on_cmap_jet(self, evt):
+        log.debug("cmap: jet")
+        self.colormap = cm.get_cmap("jet")
+        self._refresh_plot()
+
+    def on_cmap_bone(self, evt):
+        log.debug("cmap: bone")
+        self.colormap = cm.get_cmap("bone")
+        self._refresh_plot()
+
+    def on_cmap_gist_earth(self, evt):
+        log.debug("cmap: gist_earth")
+        self.colormap = cm.get_cmap("gist_earth")
+        self._refresh_plot()
+
+    def on_cmap_ocean(self, evt):
+        log.debug("cmap: ocean")
+        self.colormap = cm.get_cmap("ocean")
+        self._refresh_plot()
+
+    def on_cmap_rainbow(self, evt):
+        log.debug("cmap: rainbow")
+        self.colormap = cm.get_cmap("rainbow")
+        self._refresh_plot()
+
+    def on_cmap_rdylgn(self, evt):
+        log.debug("cmap: RdYlGn")
+        self.colormap = cm.get_cmap("RdYlGn")
+        self._refresh_plot()
+
+    def on_cmap_winter(self, evt):
+        log.debug("cmap: winter")
+        self.colormap = cm.get_cmap("winter")
+        self._refresh_plot()
+
+    def _refresh_plot(self):
+        self.draw_figure()
+        self.canvas.draw()
+
+    def draw_figure(self):
+        ls = LightSource(azdeg=315, altdeg=45)
+        blended_surface = ls.shade(self.data, cmap=self.colormap, vert_exag=5, blend_mode="overlay",
+                                   vmin=np.nanmin(self.data), vmax=np.nanmax(self.data))
+        self.axes.coastlines(resolution='50m', color='gray', linewidth=1)
+        img = self.axes.imshow(blended_surface, origin='lower', cmap=self.colormap,
+                               extent=self.geo_extent, transform=ccrs.PlateCarree())
+        img.set_clim(vmin=np.nanmin(self.data), vmax=np.nanmax(self.data))
+        # add gridlines with labels only on the left and on the bottom
+        grl = self.axes.gridlines(crs=ccrs.PlateCarree(), color='gray', draw_labels=True)
+        grl.xformatter = LONGITUDE_FORMATTER
+        grl.yformatter = LATITUDE_FORMATTER
+        grl.xlabel_style = {'size': 8}
+        grl.ylabel_style = {'size': 8}
+        grl.ylabels_right = False
+        grl.xlabels_top = False
+
+        if self.cb:
+            self.cb.on_mappable_changed(img)
+        else:
+            self.cb = plt.colorbar(img, ax=self.axes)
+        self.cb.ax.tick_params(labelsize=8)
+
+    def change_cursor(self, event):
+        self.canvas.SetCursor(wx.StockCursor(wx.CURSOR_CROSS))
+
+    def update_status_bar(self, event):
+        msg = str()
+        if event.inaxes:
+            x, y = event.xdata, event.ydata
+            msg = self.axes.format_coord(x, y)
+        self.status_bar.SetStatusText(msg, 1)
diff --git a/hdf_compass/compass_viewer/viewer.py b/hdf_compass/compass_viewer/viewer.py
index e521419..d0876b8 100644
--- a/hdf_compass/compass_viewer/viewer.py
+++ b/hdf_compass/compass_viewer/viewer.py
@@ -31,7 +31,7 @@ from hdf_compass import compass_model
 from hdf_compass import utils
 
 from .events import ID_COMPASS_OPEN
-from . import container, array, keyvalue, image, frame, text
+from . import container, array, geo_surface, geo_array, keyvalue, image, frame, text
 
 __version__ = utils.__version__
 
@@ -121,6 +121,14 @@ def open_node(node, pos=None):
         f = container.ContainerFrame(node, pos=new_pos)
         f.Show()
 
+    elif isinstance(node, compass_model.GeoSurface):
+        f = geo_surface.GeoSurfaceFrame(node, pos=new_pos)
+        f.Show()
+
+    elif isinstance(node, compass_model.GeoArray):
+        f = geo_array.GeoArrayFrame(node, pos=new_pos)
+        f.Show()
+
     elif isinstance(node, compass_model.Array):
         f = array.ArrayFrame(node, pos=new_pos)
         f.Show()
diff --git a/hdf_compass/utils/__init__.py b/hdf_compass/utils/__init__.py
index 20802d5..26f8107 100644
--- a/hdf_compass/utils/__init__.py
+++ b/hdf_compass/utils/__init__.py
@@ -19,4 +19,4 @@ log.addHandler(logging.NullHandler())
 from .utils import is_darwin, is_win, is_linux, url2path, path2url, data_url
 
 
-__version__ = "0.6.0.dev1"
+__version__ = "0.6.0"
diff --git a/setup.cfg b/setup.cfg
index b70f841..1a58bba 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,19 +1,6 @@
 [bumpversion]
-current_version = 0.6.0.dev1
+current_version = 0.6.0b2
 files = setup.py hdf_compass/utils/__init__.py docs/conf.py HDFCompass.1file.spec
-parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:\.dev(?P<dev>\d+))?
-serialize = 
-	{major}.{minor}.{patch}.dev{dev}
-	{major}.{minor}.{patch}
-
-[bumpversion:part:dev]
-values = 
-	0
-	1
-	2
-	3
-	4
-optional_value = 4
 
 [bdist_wheel]
 universal = 0
diff --git a/setup.py b/setup.py
index 1f1863d..17a19ed 100644
--- a/setup.py
+++ b/setup.py
@@ -55,7 +55,7 @@ setup_args = dict()
 
 setup_args['name'] = 'hdf_compass'
 # The adopted versioning scheme follow PEP40
-setup_args['version'] = '0.6.0.dev1'
+setup_args['version'] = '0.6.0b2'
 setup_args['url'] = 'https://github.com/HDFGroup/hdf-compass/'
 setup_args['license'] = 'BSD-like license'
 setup_args['author'] = 'HDFGroup'
@@ -104,9 +104,10 @@ setup_args['install_requires'] =\
     [
         "numpy",
         "matplotlib",
+        "cartopy",
         "h5py",
         "wxPython",
-        "hydroffice.bag",
+        "hydroffice.bag>=0.2.5",
         "pydap",
         "requests"
     ]

-- 
Alioth's /usr/local/bin/git-commit-notice on /srv/git.debian.org/git/debian-science/packages/hdf-compass.git



More information about the debian-science-commits mailing list