Repository: spencerahill/aospy

Issue:
Region definitions assume [0, 360] longitudes range
Just noticed this while using `Region.ts` on data that is on a [-180, 180] longitude grid, using the following region:

```python
sahel = Region(
    name='sahel',
    description='African Sahel',
    mask_bounds=[((10, 20), (0, 40)), ((10, 20), (342, 360))],
    do_land_mask=True
)
```

The (342, 360) bit gets dropped, since it is out of the array's (-180, 180) range, causing the region to be truncated to (0, 40).  This is physically incorrect and thus a bug.

We need to translate all region definitions and the longitude arrays on which the regional reductions are operating to a standard longitude array, which by convention should be either (-180, 180) or (0, 360).  This can be accomplished by noting that `(0, 180) + 360*i` corresponds to the Eastern Hemisphere and `(180, 360) + 360*j` corresponds to the Western hemisphere, where `i` and `j` are integers.  E.g. `i=0, j=-1` corresponds to the (-180, 180) grid.

So the steps would be

1. Convert the region's longitude bounds to aospy's chosen reference longitude.
2. Convert the array's longitudes to aospy's chosen reference longitude.
3. Perform the reduction.

Once implemented, we should be able to support longitude arrays with arbitrary start and end values.

Discussion:
spencerkclark: > But now I can't remember how we generated the tutorial notebook. Do you recall? I could manipulate the .ipynb file directly, but this seems fraught.

Yeah don't edit the the .ipynb file directly :). The easiest thing is to just open it in Jupyter, make the changes, and save it.  
spencerahill: The `calc` test suite is now failing across the board due to a problem with the tearDown, e.g. [here](https://travis-ci.org/spencerahill/aospy/jobs/370355983).

I don't see an obvious way that what I introduced caused this, although this is the first time we're seeing it.  I just added a try/except guard to see if that solves it.
spencerahill: >I just added a try/except guard to see if that solves it.

Hmm, now these have been replaced by even less intuitive ones, e.g. [here](https://travis-ci.org/spencerahill/aospy/jobs/370365778)

It seems like the example objects have somehow been corrupted.  These tests are all passing on my local machine.
spencerkclark: > Hmm, now these have been replaced by even less intuitive ones, e.g. here

I think those were always there -- see L1828-L1873 in the [old build](https://travis-ci.org/spencerahill/aospy/jobs/370355983#L1828-L1873) for example.  The tear down errors were just side effects of the results never being computed/saved to files.

Xarray recently released a new version (version 0.10.3); which version are you on locally?
spencerahill: >Xarray recently released a new version (version 0.10.3); which version are you on locally?

Good call.  I was on 10.2; after updating to 10.3 I can reproduce.  I'll open a separate issue to track this.
spencerahill: >I still need to look at the code, but I read through the docstrings (which are in general very nice!) and noticed a few minor things that I didn't want to forget.

Thanks!  FYI I've realized after this commit that the logic I implemented is incomplete (i.e. wrong)...I'm working on a better solution and will ping you when it's ready for a proper review.
spencerahill: C.f. my comment https://github.com/spencerahill/aospy/issues/267#issuecomment-385822091, I think after all that dealing with non-cyclic longitudes is probably outside of the scope of this PR (despite that being partly the original motivation).  Frankly, I am almost out of spare cycles for the next few weeks, and I want to get what's here so far in.

So we would return to the cyclic points in conjunction with how we treat region boundaries in a future PR.  @spencerkclark does that sound OK to you?  Sorry that I turned this one into such a long and winding road.

Either way, I'd appreciate a review of this PR when you get the chance.  Thanks!
spencerkclark: > So we would return to the cyclic points in conjunction with how we treat region boundaries in a future PR. @spencerkclark does that sound OK to you?

Totally fine!  I'll try and give things a review over the next few days.
spencerahill: OK, I think this is ready to go.  Will merge once tests pass.  Thanks @spencerkclark for the invaluable help.

Patch:
diff --git a/aospy/examples/example_obj_lib.py b/aospy/examples/example_obj_lib.py
index 340a868..a71f934 100644
--- a/aospy/examples/example_obj_lib.py
+++ b/aospy/examples/example_obj_lib.py
@@ -100,8 +100,10 @@ precip_conv_frac = Var(
 globe = Region(
     name='globe',
     description='Entire globe',
-    lat_bounds=(-90, 90),
-    lon_bounds=(0, 360),
+    west_bound=0,
+    east_bound=360,
+    south_bound=-90,
+    north_bound=90,
     do_land_mask=False
 )
 
@@ -109,8 +111,10 @@ globe = Region(
 tropics = Region(
     name='tropics',
     description='Tropics, defined as 30S-30N',
-    lat_bounds=(-30, 30),
-    lon_bounds=(0, 360),
+    west_bound=0,
+    east_bound=360,
+    south_bound=-30,
+    north_bound=30,
     do_land_mask=False
 )
 
diff --git a/aospy/examples/tutorial.ipynb b/aospy/examples/tutorial.ipynb
index 2040cf2..6d234ce 100644
--- a/aospy/examples/tutorial.ipynb
+++ b/aospy/examples/tutorial.ipynb
@@ -2,10 +2,7 @@
  "cells": [
   {
    "cell_type": "markdown",
-   "metadata": {
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "source": [
     "aospy Tutorial\n",
     "========\n",
@@ -14,10 +11,7 @@
   },
   {
    "cell_type": "markdown",
-   "metadata": {
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "source": [
     "Preliminaries\n",
     "-------------\n",
@@ -28,9 +22,7 @@
    "cell_type": "code",
    "execution_count": 1,
    "metadata": {
-    "collapsed": true,
-    "deletable": true,
-    "editable": true
+    "collapsed": true
    },
    "outputs": [],
    "source": [
@@ -41,10 +33,7 @@
   },
   {
    "cell_type": "markdown",
-   "metadata": {
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "source": [
     "Now we'll use the fantastic [xarray](http://xarray.pydata.org/en/stable/) package to inspect the data:"
    ]
@@ -52,11 +41,7 @@
   {
    "cell_type": "code",
    "execution_count": 2,
-   "metadata": {
-    "collapsed": false,
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "outputs": [
     {
      "data": {
@@ -90,30 +75,21 @@
   },
   {
    "cell_type": "markdown",
-   "metadata": {
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "source": [
     "We see that, in this particular model, the variable names for these two forms of precipitation are \"condensation_rain\" and \"convection_rain\", respectively.  The file also includes the coordinate arrays (\"lat\", \"time\", etc.) that indicate where in space and time the data refers to."
    ]
   },
   {
    "cell_type": "markdown",
-   "metadata": {
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "source": [
     "Now that we know where and what the data is, we'll proceed through the workflow described in the [Using aospy](http://aospy.readthedocs.io/en/latest/using-aospy.html) section of the documentation."
    ]
   },
   {
    "cell_type": "markdown",
-   "metadata": {
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "source": [
     "Describing your data\n",
     "===========\n",
@@ -132,9 +108,7 @@
    "cell_type": "code",
    "execution_count": 3,
    "metadata": {
-    "collapsed": true,
-    "deletable": true,
-    "editable": true
+    "collapsed": true
    },
    "outputs": [],
    "source": [
@@ -145,10 +119,7 @@
   },
   {
    "cell_type": "markdown",
-   "metadata": {
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "source": [
     "We then pass this to the `Run` constructor, along with a name for the run and an optional description.\n",
     "\n",
@@ -159,9 +130,7 @@
    "cell_type": "code",
    "execution_count": 4,
    "metadata": {
-    "collapsed": true,
-    "deletable": true,
-    "editable": true
+    "collapsed": true
    },
    "outputs": [],
    "source": [
@@ -175,10 +144,7 @@
   },
   {
    "cell_type": "markdown",
-   "metadata": {
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "source": [
     "Models\n",
     "------\n",
@@ -194,9 +160,7 @@
    "cell_type": "code",
    "execution_count": 5,
    "metadata": {
-    "collapsed": true,
-    "deletable": true,
-    "editable": true
+    "collapsed": true
    },
    "outputs": [],
    "source": [
@@ -213,10 +177,7 @@
   },
   {
    "cell_type": "markdown",
-   "metadata": {
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "source": [
     "Projects\n",
     "--------\n",
@@ -228,9 +189,7 @@
    "cell_type": "code",
    "execution_count": 6,
    "metadata": {
-    "collapsed": true,
-    "deletable": true,
-    "editable": true
+    "collapsed": true
    },
    "outputs": [],
    "source": [
@@ -245,20 +204,14 @@
   },
   {
    "cell_type": "markdown",
-   "metadata": {
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "source": [
     "This extra `Proj` level of organization may seem like overkill for this simple example, but it really comes in handy once you start using aospy for more than one project."
    ]
   },
   {
    "cell_type": "markdown",
-   "metadata": {
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "source": [
     "Defining physical quantities and regions\n",
     "======================\n",
@@ -275,9 +228,7 @@
    "cell_type": "code",
    "execution_count": 7,
    "metadata": {
-    "collapsed": true,
-    "deletable": true,
-    "editable": true
+    "collapsed": true
    },
    "outputs": [],
    "source": [
@@ -299,10 +250,7 @@
   },
   {
    "cell_type": "markdown",
-   "metadata": {
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "source": [
     "When it comes time to load data corresponding to either of these from one or more particular netCDF files, aospy will search for variables matching either `name` or any of the names in `alt_names`, stopping at the first successful one. This makes the common problem of model-specific variable names a breeze: if you end up with data with a new name for your variable, just add it to `alt_names`.\n",
     "\n",
@@ -315,9 +263,7 @@
    "cell_type": "code",
    "execution_count": 8,
    "metadata": {
-    "collapsed": true,
-    "deletable": true,
-    "editable": true
+    "collapsed": true
    },
    "outputs": [],
    "source": [
@@ -348,10 +294,7 @@
   },
   {
    "cell_type": "markdown",
-   "metadata": {
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "source": [
     "Notice the `func` and `variables` attributes that weren't in the prior `Var` constuctors. These signify the function to use and the physical quantities to pass to that function in order to compute the quantity.\n",
     "\n",
@@ -373,9 +316,7 @@
    "cell_type": "code",
    "execution_count": 9,
    "metadata": {
-    "collapsed": true,
-    "deletable": true,
-    "editable": true
+    "collapsed": true
    },
    "outputs": [],
    "source": [
@@ -383,16 +324,20 @@
     "globe = Region(\n",
     "    name='globe',\n",
     "    description='Entire globe',\n",
-    "    lat_bounds=(-90, 90),\n",
-    "    lon_bounds=(0, 360),\n",
+    "    west_bound=0,\n",
+    "    east_bound=360,\n",
+    "    south_bound=-90,\n",
+    "    north_bound=90,\n",
     "    do_land_mask=False\n",
     ")\n",
     "\n",
     "tropics = Region(\n",
     "    name='tropics',\n",
     "    description='Global tropics, defined as 30S-30N',\n",
-    "    lat_bounds=(-30, 30),\n",
-    "    lon_bounds=(0, 360),\n",
+    "    west_bound=0,\n",
+    "    east_bound=360,\n",
+    "    south_bound=-30,\n",
+    "    north_bound=30,\n",
     "    do_land_mask=False\n",
     ")\n",
     "example_proj.regions = [globe, tropics]"
@@ -400,10 +345,7 @@
   },
   {
    "cell_type": "markdown",
-   "metadata": {
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "source": [
     "We now have all of the needed metadata in place. So let's start crunching numbers!\n",
     "\n",
@@ -422,9 +364,7 @@
    "cell_type": "code",
    "execution_count": 10,
    "metadata": {
-    "collapsed": true,
-    "deletable": true,
-    "editable": true
+    "collapsed": true
    },
    "outputs": [],
    "source": [
@@ -451,10 +391,7 @@
   },
   {
    "cell_type": "markdown",
-   "metadata": {
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "source": [
     "See the `api-ref` on `aospy.submit_mult_calcs` for more on `calc_suite_specs`, including accepted values for each key.\n",
     "\n",
@@ -465,9 +402,7 @@
    "cell_type": "code",
    "execution_count": 11,
    "metadata": {
-    "collapsed": true,
-    "deletable": true,
-    "editable": true
+    "collapsed": true
    },
    "outputs": [],
    "source": [
@@ -477,10 +412,7 @@
   },
   {
    "cell_type": "markdown",
-   "metadata": {
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "source": [
     "Now let's submit this for execution:"
    ]
@@ -488,11 +420,7 @@
   {
    "cell_type": "code",
    "execution_count": 12,
-   "metadata": {
-    "collapsed": false,
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "outputs": [
     {
      "name": "stderr",
@@ -558,10 +486,7 @@
   },
   {
    "cell_type": "markdown",
-   "metadata": {
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "source": [
     "This permutes over all of the parameter settings in `calc_suite_specs`, generating and executing the resulting calculation. In this case, it will compute all four variables and perform annual averages, both for each gridpoint and regionally averaged.\n",
     "\n",
@@ -570,10 +495,7 @@
   },
   {
    "cell_type": "markdown",
-   "metadata": {
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "source": [
     "Results\n",
     "-------\n",
@@ -584,11 +506,7 @@
   {
    "cell_type": "code",
    "execution_count": 13,
-   "metadata": {
-    "collapsed": false,
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "outputs": [
     {
      "data": {
@@ -610,10 +528,7 @@
   },
   {
    "cell_type": "markdown",
-   "metadata": {
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "source": [
     "Each `Calc` object includes the paths to the output"
    ]
@@ -621,11 +536,7 @@
   {
    "cell_type": "code",
    "execution_count": 14,
-   "metadata": {
-    "collapsed": false,
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "outputs": [
     {
      "data": {
@@ -645,10 +556,7 @@
   },
   {
    "cell_type": "markdown",
-   "metadata": {
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "source": [
     "and the results of each output type"
    ]
@@ -656,11 +564,7 @@
   {
    "cell_type": "code",
    "execution_count": 15,
-   "metadata": {
-    "collapsed": false,
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "outputs": [
     {
      "data": {
@@ -715,10 +619,7 @@
   },
   {
    "cell_type": "markdown",
-   "metadata": {
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "source": [
     "Note: you may have noticed that `subset_...` and `raw_...` coordinates have years 1678 and later, when our data was from model years 4 through 6. This is because [technical details upstream](http://pandas.pydata.org/pandas-docs/stable/timeseries.html#timestamp-limitations) limit the range of supported whole years to 1678-2262.\n",
     "\n",
@@ -733,11 +634,7 @@
   {
    "cell_type": "code",
    "execution_count": 16,
-   "metadata": {
-    "collapsed": false,
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "outputs": [
     {
      "name": "stderr",
@@ -783,10 +680,7 @@
   },
   {
    "cell_type": "markdown",
-   "metadata": {
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "source": [
     "We see that precipitation maximizes at the equator and has a secondary maximum in the mid-latitudes. Also, the convective precipitation dominates the total in the Tropics, but moving poleward the gridscale condensation plays an increasingly larger fractional role (note different colorscales in each panel).\n",
     "\n",
@@ -799,11 +693,7 @@
   {
    "cell_type": "code",
    "execution_count": 17,
-   "metadata": {
-    "collapsed": false,
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "outputs": [
     {
      "name": "stdout",
@@ -866,20 +756,14 @@
   },
   {
    "cell_type": "markdown",
-   "metadata": {
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "source": [
     "As was evident from the plots, we see that most precipitation (80.8%) in the tropics comes from convective rainfall, but averaged over the globe the large-scale condensation is a more equal player (40.2% for large-scale, 59.8% for convective)."
    ]
   },
   {
    "cell_type": "markdown",
-   "metadata": {
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "source": [
     "Beyond this simple example\n",
     "==============\n",
@@ -913,11 +797,7 @@
   {
    "cell_type": "code",
    "execution_count": 18,
-   "metadata": {
-    "collapsed": false,
-    "deletable": true,
-    "editable": true
-   },
+   "metadata": {},
    "outputs": [],
    "source": [
     "# Optional: remove created files\n",
@@ -942,9 +822,36 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.6.0"
+   "version": "3.6.3"
+  },
+  "latex_envs": {
+   "LaTeX_envs_menu_present": true,
+   "autocomplete": true,
+   "bibliofile": "biblio.bib",
+   "cite_by": "apalike",
+   "current_citInitial": 1,
+   "eqLabelWithNumbers": true,
+   "eqNumInitial": 1,
+   "hotkeys": {
+    "equation": "Ctrl-E",
+    "itemize": "Ctrl-I"
+   },
+   "labels_anchors": false,
+   "latex_user_defs": false,
+   "report_style_numbering": false,
+   "user_envs_cfg": false
+  },
+  "toc": {
+   "nav_menu": {},
+   "number_sections": true,
+   "sideBar": true,
+   "skip_h1_title": false,
+   "toc_cell": false,
+   "toc_position": {},
+   "toc_section_display": "block",
+   "toc_window_display": false
   }
  },
  "nbformat": 4,
- "nbformat_minor": 0
+ "nbformat_minor": 1
 }
diff --git a/aospy/region.py b/aospy/region.py
index 2d27eed..546e88c 100644
--- a/aospy/region.py
+++ b/aospy/region.py
@@ -1,33 +1,24 @@
 """Functionality pertaining to aggregating data over geographical regions."""
+from collections import namedtuple
 import logging
 
 import numpy as np
 
-from . import internal_names
+from .internal_names import (
+    LAND_MASK_STR,
+    LAT_STR,
+    LON_STR,
+    SFC_AREA_STR,
+    YEAR_STR
+)
+from .utils.longitude import _maybe_cast_to_lon
 
 
-def _add_to_mask(data, lat_bounds, lon_bounds):
-    """Add mask spanning given lat-lon rectangle."""
-    mask_lat = ((data[internal_names.LAT_STR] > lat_bounds[0]) &
-                (data[internal_names.LAT_STR] < lat_bounds[1]))
-    return mask_lat & ((data[internal_names.LON_STR] > lon_bounds[0]) &
-                       (data[internal_names.LON_STR] < lon_bounds[1]))
-
-
-def _make_mask(data, mask_bounds):
-    """Construct the mask that defines this region."""
-    # For each set of bounds add to the conditional.
-    mask = False
-    for lat_bounds, lon_bounds in mask_bounds:
-        mask |= _add_to_mask(data, lat_bounds, lon_bounds)
-    return mask
-
-
-def _get_land_mask(data, do_land_mask):
+def _get_land_mask(data, do_land_mask, land_mask_str=LAND_MASK_STR):
     if not do_land_mask:
         return 1
     try:
-        land_mask = data.land_mask.copy()
+        land_mask = data[land_mask_str].copy()
     except AttributeError:
         # TODO: Implement aospy built-in land mask to default to.
         msg = ("No land mask found.  Using empty mask, which amounts to "
@@ -38,12 +29,11 @@ def _get_land_mask(data, do_land_mask):
         return 1
     try:
         percent_bool = land_mask.units.lower() in ('%', 'percent')
-        logging.debug("Converting land mask from 0-100 to 0.0-1.0")
     except AttributeError:
-        # Wrong for the edge case where no grid cell is 100% land.
-        percent_bool = land_mask.max() == 100
+        percent_bool = np.any(land_mask > 1)
     if percent_bool:
         land_mask *= 0.01
+        logging.debug("Converting land mask from 0-100 to 0.0-1.0")
     if do_land_mask is True:
         return land_mask
     if do_land_mask == 'ocean':
@@ -56,9 +46,18 @@ def _get_land_mask(data, do_land_mask):
     raise ValueError(msg)
 
 
-def _sum_over_lat_lon(arr):
-    """Sum an array over the latitude and longitude dimensions."""
-    return arr.sum(internal_names.LAT_STR).sum(internal_names.LON_STR)
+class BoundsRect(namedtuple('BoundsRect', ['west', 'east', 'south', 'north'])):
+    """Bounding longitudes and latitudes of a given lat-lon rectangle."""
+    def __new__(cls, west, east, south, north):
+        new_west = _maybe_cast_to_lon(west, strict=True)
+        new_east = _maybe_cast_to_lon(east, strict=True)
+        return super(BoundsRect, cls).__new__(cls, new_west, new_east,
+                                              south, north)
+
+    def __repr__(self):
+        return ("BoundsRect(west={0}, east={1}, south={2}, "
+                "north={3}".format(self.west, self.east, self.south,
+                                   self.north))
 
 
 class Region(object):
@@ -92,14 +91,25 @@ class Region(object):
 
     """
 
-    def __init__(self, name='', description='', lon_bounds=[], lat_bounds=[],
-                 mask_bounds=[], do_land_mask=False):
+    def __init__(self, name='', description='', west_bound=None,
+                 east_bound=None, south_bound=None, north_bound=None,
+                 mask_bounds=None, do_land_mask=False):
         """Instantiate a Region object.
 
-        If a region spans across the endpoint of the data's longitude array
-        (i.e.  it crosses the Prime Meridian for data with longitudes spanning
-        0 to 360), it must be defined as the union of two sections extending to
-        the east and to the west of the Prime Meridian.
+        Note that longitudes spanning (-180, 180), (0, 360), or any other range
+        are all supported: -180 to 0, 180 to 360, etc. are interpreted as the
+        western hemisphere, and 0-180, 360-540, etc. are interpreted as the
+        eastern hemisphere.  This is true both of the region definition and of
+        any data upon which the region mask is applied.
+
+        E.g. suppose some of your data is defined on a -180 to 180 longitude
+        grid, some of it is defined on a 0 to 360 grid, and some of it is
+        defined on a -70 to 290 grid.  A single Region object will work with
+        all three of these.
+
+        Conversely, latitudes are always treated as -90 as the South Pole, 0 as
+        the Equator, and 90 as the North Pole.  Latitudes larger than 90 are
+        not physically meaningful.
 
         Parameters
         ----------
@@ -109,14 +119,33 @@ class Region(object):
         description : str, optional
              A description of the region.  This is not used internally by
              aospy; it is solely for the user's information.
-        lon_bounds, lat_bounds : length-2 sequence, optional
-             The longitude and latitude bounds of the region.  If the region
-             boundaries are more complicated than a single lat-lon rectangle,
-             use `mask_bounds` instead.
+        west_bound, east_bound : { scalar, aospy.Longitude }, optional
+             The western and eastern boundaries of the region.  All input
+             longitudes are casted to ``aospy.Longitude`` objects, which
+             essentially maps them to a 180W to 180E grid.  The region's
+             longitudes always start at ``west_bound`` and move toward the east
+             until reaching ``east_bound``.  This means that there are two
+             distinct cases:
+
+             - If, after this translation, west_bound is less than east_bound,
+               the region includes the points east of west_bound and west of
+               east_bound.
+             - If west_bound greater than east_bound, then the
+               region is treated as wrapping around the dateline, i.e. it's
+               western-most point is east_bound, and it includes all points
+               moving east from there until west_bound.
+
+             If the region boundaries are more complicated than a single
+             lat-lon rectangle, use `mask_bounds` instead.
+
+        south_bound, north_bound : scalar, optional
+             The southern, and northern boundaries, respectively, of the
+             region.  If the region boundaries are more complicated than a
+             single lat-lon rectangle, use `mask_bounds` instead.
         mask_bounds : sequence, optional
-             Each element is a length-2 tuple of the format `(lat_bounds,
-             lon_bounds)`, where each of `lat_bounds` and `lon_bounds` are
-             of the form described above.
+             Each element is a length-4 sequence of the format `(west_bound,
+             east_bound, south_bound, north_bound)`, where each of these
+             `_bound` arguments is of the form described above.
         do_land_mask : { False, True, 'ocean', 'strict_land', 'strict_ocean'},
                        optional
              Determines what, if any, land mask is applied in addition to the
@@ -130,63 +159,197 @@ class Region(object):
 
         Examples
         --------
-        Define a region spanning the entire globe
+        Define a region spanning the entire globe:
+
+        >>> globe = Region(name='globe', west_bound=0, east_bound=360,
+        ...                south_bound=-90, north_bound=90, do_land_mask=False)
 
-        >>> globe = Region(name='globe', lat_bounds=(-90, 90),
-        ...                lon_bounds=(0, 360), do_land_mask=False)
+        Longitudes are handled as cyclic, so this definition could have
+        equivalently used `west_bound=-180, east_bound=180` or `west_bound=200,
+        east_bound=560`, or anything else that spanned 360 degrees total.
 
-        Define a region corresponding to the Sahel region of Africa, which
-        we'll define as land points within 10N-20N latitude and 18W-40E
-        longitude.  Because this region crosses the 0 degrees longitude point,
-        it has to be defined using `mask_bounds` as the union of two lat-lon
-        rectangles.
+        Define a region corresponding to land in the mid-latitudes, which we'll
+        define as land points within 30-60 degrees latitude in both
+        hemispheres.  Because this region is the union of multiple lat-lon
+        rectangles, it has to be defined using `mask_bounds`:
 
-        >>> sahel = Region(name='sahel', do_land_mask=True,
-        ...                mask_bounds=[((10, 20), (0, 40)),
-        ...                             ((10, 20), (342, 360))])
+        >>> land_mid_lats = Region(name='land_midlat', do_land_mask=True,
+        ...                        mask_bounds=[(-180, 180, 30, 60),
+        ...                                     (-180, 180, -30, -60)])
+
+        Define a region spanning the southern Tropical Atlantic ocean, which
+        we'll take to be all ocean points between 60W and 30E and between the
+        Equator and 30S:
+
+        >>> atl_south_trop = Region(name='atl_sh_trop', west_bound=-60,
+        ...                         east_bound=30, south_bound=-30,
+        ...                         north_bound=0, do_land_mask='ocean')
+
+        Define the "opposite" region, i.e. all ocean points in the southern
+        Tropics *outside* of the Atlantic.  We simply swap ``west_bound`` and
+        ``east_bound`` of the previous example:
+
+        >>> non_atl_south_trop = Region(name='non_atl_sh_trop', west_bound=30,
+        ...                         east_bound=-60, south_bound=-30,
+        ...                         north_bound=0, do_land_mask='ocean')
 
         """
         self.name = name
         self.description = description
-        if lon_bounds and lat_bounds and not mask_bounds:
-            self.mask_bounds = [(lat_bounds, lon_bounds)]
-        else:
-            self.mask_bounds = mask_bounds
         self.do_land_mask = do_land_mask
 
+        if mask_bounds is None:
+            self.mask_bounds = tuple([BoundsRect(west_bound, east_bound,
+                                                 south_bound, north_bound)])
+        else:
+            bounds = []
+            for rect_bounds in mask_bounds:
+                if len(rect_bounds) != 4:
+                    raise ValueError("Each element of `mask_bounds` must be a "
+                                     "length-4 array with values (west, east, "
+                                     "south, north).  Value given: "
+                                     "{}".format(rect_bounds))
+                else:
+                    bounds.append(BoundsRect(*rect_bounds))
+            self.mask_bounds = tuple(bounds)
+
     def __str__(self):
         return 'Geographical region "' + self.name + '"'
 
-    __repr__ = __str__
+    def _make_mask(self, data, lon_str=LON_STR, lat_str=LAT_STR):
+        """Construct the mask that defines a region on a given data's grid."""
+        mask = False
+        for west, east, south, north in self.mask_bounds:
+            if west < east:
+                mask_lon = (data[lon_str] > west) & (data[lon_str] < east)
+            else:
+                mask_lon = (data[lon_str] < west) | (data[lon_str] > east)
+            mask_lat = (data[lat_str] > south) & (data[lat_str] < north)
+            mask |= mask_lon & mask_lat
+        return mask
+
+    def mask_var(self, data, lon_cyclic=True, lon_str=LON_STR,
+                 lat_str=LAT_STR):
+        """Mask the given data outside this region.
+
+        Parameters
+        ----------
+        data : xarray.DataArray
+            The array to be regionally masked.
+        lon_cyclic : bool, optional (default True)
+            Whether or not the longitudes of ``data`` span the whole globe,
+            meaning that they should be wrapped around as necessary to cover
+            the Region's full width.
+        lon_str, lat_str : str, optional
+            The names of the longitude and latitude dimensions, respectively,
+            in the data to be masked.  Defaults are
+            ``aospy.internal_names.LON_STR`` and
+            ``aospy.internal_names.LON_STR``, respectively.
+
+        Returns
+        -------
+        xarray.DataArray
+            The original array with points outside of the region masked.
 
-    def mask_var(self, data):
-        """Mask the data of the given variable outside the region."""
-        return data.where(_make_mask(data, self.mask_bounds))
+        """
+        # TODO: is this still necessary?
+        if not lon_cyclic:
+            if self.west_bound > self.east_bound:
+                raise ValueError("Longitudes of data to be masked are "
+                                 "specified as non-cyclic, but Region's "
+                                 "definition requires wraparound longitudes.")
+        masked = data.where(self._make_mask(data, lon_str=lon_str,
+                                            lat_str=lat_str))
+        return masked
+
+    def ts(self, data, lon_cyclic=True, lon_str=LON_STR, lat_str=LAT_STR,
+           land_mask_str=LAND_MASK_STR, sfc_area_str=SFC_AREA_STR):
+        """Create yearly time-series of region-averaged data.
 
-    def ts(self, data):
-        """Create time-series of region-average data."""
-        data_masked = self.mask_var(data)
-        sfc_area = data.sfc_area
-        land_mask = _get_land_mask(data, self.do_land_mask)
+        Parameters
+        ----------
+        data : xarray.DataArray
+            The array to create the regional timeseries of
+        lon_cyclic : { None, True, False }, optional (default True)
+            Whether or not the longitudes of ``data`` span the whole globe,
+            meaning that they should be wrapped around as necessary to cover
+            the Region's full width.
+        lat_str, lon_str, land_mask_str, sfc_area_str : str, optional
+            The name of the latitude, longitude, land mask, and surface area
+            coordinates, respectively, in ``data``.  Defaults are the
+            corresponding values in ``aospy.internal_names``.
+
+        Returns
+        -------
+        xarray.DataArray
+            The timeseries of values averaged within the region and within each
+            year, one value per year.
 
-        weights = self.mask_var(sfc_area) * land_mask
+        """
+        data_masked = self.mask_var(data, lon_cyclic=lon_cyclic,
+                                    lon_str=lon_str, lat_str=lat_str)
+        sfc_area = data[sfc_area_str]
+        sfc_area_masked = self.mask_var(sfc_area, lon_cyclic=lon_cyclic,
+                                        lon_str=lon_str, lat_str=lat_str)
+        land_mask = _get_land_mask(data, self.do_land_mask,
+                                   land_mask_str=land_mask_str)
+        weights = sfc_area_masked * land_mask
         # Mask weights where data values are initially invalid in addition
         # to applying the region mask.
         weights = weights.where(np.isfinite(data))
-        sum_weights = _sum_over_lat_lon(weights)
-        return (_sum_over_lat_lon(data_masked*sfc_area*land_mask) /
-                sum_weights)
-
-    def av(self, data):
-        """Time average of region-average time-series."""
-        ts_ = self.ts(data)
-        if 'year' not in ts_.coords:
-            return ts_
-        return ts_.mean('year')
-
-    def std(self, data):
-        """Standard deviation of region-average time-series."""
-        ts_ = self.ts(data)
-        if 'year' not in ts_.coords:
-            return ts_
-        return ts_.std('year')
+        weights_reg_sum = weights.sum(lon_str).sum(lat_str)
+        data_reg_sum = (data_masked * sfc_area_masked *
+                        land_mask).sum(lat_str).sum(lon_str)
+        return data_reg_sum / weights_reg_sum
+
+    def av(self, data, lon_str=LON_STR, lat_str=LAT_STR,
+           land_mask_str=LAND_MASK_STR, sfc_area_str=SFC_AREA_STR):
+        """Time-average of region-averaged data.
+
+        Parameters
+        ----------
+        data : xarray.DataArray
+            The array to compute the regional time-average of
+        lat_str, lon_str, land_mask_str, sfc_area_str : str, optional
+            The name of the latitude, longitude, land mask, and surface area
+            coordinates, respectively, in ``data``.  Defaults are the
+            corresponding values in ``aospy.internal_names``.
+
+        Returns
+        -------
+        xarray.DataArray
+            The region-averaged and time-averaged data.
+
+        """
+        ts = self.ts(data, lon_str=lon_str, lat_str=lat_str,
+                     land_mask_str=land_mask_str, sfc_area_str=sfc_area_str)
+        if YEAR_STR not in ts.coords:
+            return ts
+        else:
+            return ts.mean(YEAR_STR)
+
+    def std(self, data, lon_str=LON_STR, lat_str=LAT_STR,
+            land_mask_str=LAND_MASK_STR, sfc_area_str=SFC_AREA_STR):
+        """Temporal standard deviation of region-averaged data.
+
+        Parameters
+        ----------
+        data : xarray.DataArray
+            The array to compute the regional time-average of
+        lat_str, lon_str, land_mask_str, sfc_area_str : str, optional
+            The name of the latitude, longitude, land mask, and surface area
+            coordinates, respectively, in ``data``.  Defaults are the
+            corresponding values in ``aospy.internal_names``.
+
+        Returns
+        -------
+        xarray.DataArray
+            The temporal standard deviation of the region-averaged data
+
+        """
+        ts = self.ts(data, lon_str=lon_str, lat_str=lat_str,
+                     land_mask_str=land_mask_str, sfc_area_str=sfc_area_str)
+        if YEAR_STR not in ts.coords:
+            return ts
+        else:
+            return ts.std(YEAR_STR)
diff --git a/aospy/utils/__init__.py b/aospy/utils/__init__.py
index 36f40a3..e9da584 100644
--- a/aospy/utils/__init__.py
+++ b/aospy/utils/__init__.py
@@ -1,6 +1,9 @@
 """Subpackage comprising various utility functions used elsewhere in aospy."""
 from . import io
+from . import longitude
+from .longitude import Longitude
 from . import times
 from . import vertcoord
 
-__all__ = ['io', 'times', 'vertcoord']
+
+__all__ = ['Longitude', 'io', 'longitude', 'times', 'vertcoord']
diff --git a/aospy/utils/longitude.py b/aospy/utils/longitude.py
new file mode 100644
index 0000000..f1d7e44
--- /dev/null
+++ b/aospy/utils/longitude.py
@@ -0,0 +1,253 @@
+#!/usr/bin/env python
+"""Functionality relating to parsing and comparing longitudes."""
+
+import numpy as np
+import xarray as xr
+
+
+def lon_to_0360(lon):
+    """Convert longitude(s) to be within [0, 360).
+
+    The Eastern hemisphere corresponds to 0 <= lon + (n*360) < 180, and the
+    Western Hemisphere corresponds to 180 <= lon + (n*360) < 360, where 'n' is
+    any integer (positive, negative, or zero).
+
+    Parameters
+    ----------
+    lon : scalar or sequence of scalars
+        One or more longitude values to be converted to lie in the [0, 360)
+        range
+
+    Returns
+    -------
+        If ``lon`` is a scalar, then a scalar of the same type in the range [0,
+        360).  If ``lon`` is array-like, then an array-like of the same type
+        with each element a scalar in the range [0, 360).
+
+    """
+    quotient = lon // 360
+    return lon - quotient*360
+
+
+def _lon_in_west_hem(lon):
+    if lon_to_0360(lon) >= 180:
+        return True
+    else:
+        return False
+
+
+def lon_to_pm180(lon):
+    """Convert longitude(s) to be within [-180, 180).
+
+    The Eastern hemisphere corresponds to 0 <= lon + (n*360) < 180, and the
+    Western Hemisphere corresponds to 180 <= lon + (n*360) < 360, where 'n' is
+    any integer (positive, negative, or zero).
+
+    Parameters
+    ----------
+    lon : scalar or sequence of scalars
+        One or more longitude values to be converted to lie in the [-180, 180)
+        range
+
+    Returns
+    -------
+        If ``lon`` is a scalar, then a scalar of the same type in the range
+        [-180, 180).  If ``lon`` is array-like, then an array-like of the same
+        type with each element a scalar in the range [-180, 180).
+
+    """
+    lon0360 = lon_to_0360(lon)
+    if _lon_in_west_hem(lon0360):
+        return lon0360 - 360
+    else:
+        return lon0360
+
+
+def _maybe_cast_to_lon(obj, strict=False):
+    if isinstance(obj, Longitude):
+        return obj
+    try:
+        return Longitude(obj)
+    except (ValueError, TypeError) as e:
+        if strict:
+            raise type(e)(str(e))
+        else:
+            return obj
+
+
+def _other_to_lon(func):
+    """Wrapper for casting Longitude operator arguments to Longitude"""
+    def func_other_to_lon(obj, other):
+        return func(obj, _maybe_cast_to_lon(other))
+    return func_other_to_lon
+
+
+class Longitude(object):
+    """Geographic longitude.
+
+    Enables unambiguous comparison of longitudes using the standard comparison
+    operators, regardless of they were initially represented with a 0 to 360
+    convention, -180 to 180 convention, or anything else, and even if the
+    original convention differs between them.
+
+    Specifically, the ``<`` operator assesses if the first object is to the
+    west of the second object, with the standard convention that longitudes in
+    the Western Hemisphere are always to the west of longitudes in the Eastern
+    Hemisphere.  The ``>`` operator is defined analogously.  ``==``, ``>=``,
+    and ``<=`` are also all defined.
+
+    In addition to other Longitude objects, the operators can be used to
+    compare a Longitude object to anything that can be casted to a Longitude
+    object, or to any sequence (e.g. a list or xarray.DataArray) whose elements
+    can be casted to Longitude objects.
+
+    """
+    def __init__(self, value):
+        """
+        Parameters
+        ----------
+        value : {scalar, str}
+            Scalars get converted to longitudes using the convention that 0-180
+            corresponds to the Eastern Hemisphere, 180-360 corresponds to the
+            Western Hemisphere, 360-540 the Eastern Hemisphere, and so on,
+            including for negative numbers.
+
+            Strings must be castable to a float or be a positive number in the
+            range 0-180 followed by a single letter 'e' or 'w' (case
+            insensitive).  For example, ``Longitude('10w')`` would yield a
+            ``Longitude`` object corresponding to 10 degrees west longitude.
+        """
+        try:
+            val_as_float = float(value)
+        except (ValueError, TypeError):
+            if not isinstance(value, str):
+                raise ValueError('value must be a scalar or a string')
+            if value[-1].lower() not in ('w', 'e'):
+                raise ValueError("string inputs must end in 'e' or 'w'")
+            try:
+                lon_value = float(value[:-1])
+            except ValueError:
+                raise ValueError('improperly formatted string')
+            if (lon_value < 0) or (lon_value > 180):
+                raise ValueError('Value given as strings with hemisphere '
+                                 'identifier must have numerical values '
+                                 'within 0 and +180.  Value given: '
+                                 '{}'.format(lon_value))
+            self._longitude = lon_value
+            self._hemisphere = value[-1].upper()
+        else:
+            lon_pm180 = lon_to_pm180(val_as_float)
+            if _lon_in_west_hem(val_as_float):
+                self._longitude = abs(lon_pm180)
+                self._hemisphere = 'W'
+            else:
+                self._longitude = lon_pm180
+                self._hemisphere = 'E'
+
+    @property
+    def longitude(self):
+        """(scalar) The unsigned numerical value of the longitude.
+
+        Always in the range 0 to 180.  Must be combined with the ``hemisphere``
+        attribute to specify the exact latitude.
+
+        """
+        return self._longitude
+
+    @longitude.setter
+    def longitude(self, value):
+        raise ValueError("'longitude' property cannot be modified after "
+                         "Longitude object has been created.")
+
+    @property
+    def hemisphere(self):
+        """{'W', 'E'} The longitude's hemisphere, either western or eastern."""
+        return self._hemisphere
+
+    @hemisphere.setter
+    def hemisphere(self, value):
+        raise ValueError("'hemisphere' property cannot be modified after "
+                         "Longitude object has been created.")
+
+    def __repr__(self):
+        return "Longitude('{0}{1}')".format(self.longitude, self.hemisphere)
+
+    @_other_to_lon
+    def __eq__(self, other):
+        if isinstance(other, Longitude):
+            return (self.hemisphere == other.hemisphere and
+                    self.longitude == other.longitude)
+        else:
+            return xr.apply_ufunc(np.equal, other, self)
+
+    @_other_to_lon
+    def __lt__(self, other):
+        if isinstance(other, Longitude):
+            if self.hemisphere == 'W':
+                if other.hemisphere == 'E':
+                    return True
+                else:
+                    return self.longitude > other.longitude
+            else:
+                if other.hemisphere == 'W':
+                    return False
+                else:
+                    return self.longitude < other.longitude
+        else:
+            return xr.apply_ufunc(np.greater, other, self)
+
+    @_other_to_lon
+    def __gt__(self, other):
+        if isinstance(other, Longitude):
+            if self.hemisphere == 'W':
+                if other.hemisphere == 'E':
+                    return False
+                else:
+                    return self.longitude < other.longitude
+            else:
+                if other.hemisphere == 'W':
+                    return True
+                else:
+                    return self.longitude > other.longitude
+        else:
+            return xr.apply_ufunc(np.less, other, self)
+
+    @_other_to_lon
+    def __le__(self, other):
+        if isinstance(other, Longitude):
+            return self < other or self == other
+        else:
+            return xr.apply_ufunc(np.greater_equal, other, self)
+
+    @_other_to_lon
+    def __ge__(self, other):
+        if isinstance(other, Longitude):
+            return self > other or self == other
+        else:
+            return xr.apply_ufunc(np.less_equal, other, self)
+
+    def to_0360(self):
+        """Convert longitude to its numerical value within [0, 360)."""
+        if self.hemisphere == 'W':
+            return -1*self.longitude + 360
+        else:
+            return self.longitude
+
+    def to_pm180(self):
+        """Convert longitude to its numerical value within [-180, 180)."""
+        if self.hemisphere == 'W':
+            return -1*self.longitude
+        else:
+            return self.longitude
+
+    @_other_to_lon
+    def __add__(self, other):
+        return Longitude(self.to_0360() + other.to_0360())
+
+    @_other_to_lon
+    def __sub__(self, other):
+        return Longitude(self.to_0360() - other.to_0360())
+
+
+if __name__ == '__main__':
+    pass
diff --git a/docs/api.rst b/docs/api.rst
index 4d258ba..79e2954 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -187,8 +187,8 @@ Utilities
 
 aospy includes a number of utility functions that are used internally
 and may also be useful to users for their own purposes.  These include
-functions pertaining to input/output (IO), time arrays, andvertical
-coordinates.
+functions pertaining to input/output (IO), longitudes, time arrays,
+and vertical coordinates.
 
 utils.io
 --------
@@ -197,6 +197,13 @@ utils.io
     :members:
     :undoc-members:
 
+utils.longitude
+---------------
+
+.. automodule:: aospy.utils.longitude
+    :members:
+    :undoc-members:
+
 utils.times
 -----------
 
diff --git a/docs/examples.rst b/docs/examples.rst
index 2a26075..6402eba 100644
--- a/docs/examples.rst
+++ b/docs/examples.rst
@@ -116,7 +116,7 @@ a name for the run and an optional description.
    float32 data to float64.  This behavior is turned on by default.  If you
    would like to disable this behavior you can set the ``upcast_float32``
    argument in your ``DataLoader`` constructors to ``False``.
-   
+
 Models
 ======
 
@@ -311,16 +311,20 @@ whole globe and at the Tropics:
     globe = Region(
         name='globe',
         description='Entire globe',
-        lat_bounds=(-90, 90),
-        lon_bounds=(0, 360),
+        west_bound=0,
+        east_bound=360,
+        south_bound=-90,
+        north_bound=90,
         do_land_mask=False
     )
 
     tropics = Region(
 	name='tropics',
 	description='Global tropics, defined as 30S-30N',
-	lat_bounds=(-30, 30),
-	lon_bounds=(0, 360),
+        west_bound=0,
+        east_bound=360,
+        south_bound=-30,
+        north_bound=30,
 	do_land_mask=False
     )
     example_proj.regions = [globe, tropics]
diff --git a/docs/whats-new.rst b/docs/whats-new.rst
index 6ce763e..66af034 100644
--- a/docs/whats-new.rst
+++ b/docs/whats-new.rst
@@ -11,6 +11,11 @@ v0.3 (unreleased)
 Breaking Changes
 ~~~~~~~~~~~~~~~~
 
+- ``aospy.Region`` no longer can be instantiated using ``lat_bounds``
+  and ``lon_bounds`` keywords.  These have been replaced with the more
+  explicit ``east_bound``, ``west_bound``, ``south_bound``, and
+  ``north_bound`` (:pull:`266`).  By `Spencer Hill
+  <https://github.com/spencerahill>`_.
 - Drop support for Python 3.4, since our core upstream dependency
   xarray is also dropping it as of their 0.11 release (:pull:`255`).
   By `Spencer Hill <https://github.com/spencerahill>`_.
@@ -39,6 +44,17 @@ Documentation
 Enhancements
 ~~~~~~~~~~~~
 
+- Create ``utils.longitude`` module and ``Longitude`` class for
+  representing and comparing longitudes.  Used internally by
+  ``aospy.Region`` to construct masks, but could also be useful for
+  users outside the standard aospy workflow (:pull:`266`).  By
+  `Spencer Hill <https://github.com/spencerahill>`_.
+- Add support for ``Region`` methods ``mask_var``, ``ts``, ``av``, and
+  ``std`` for data that doesn't conform to aospy naming conventions,
+  making these methods now useful in more interactive contexts in
+  addition to within the standard main script-based work flow
+  (:pull:`266`).  By `Spencer Hill
+  <https://github.com/spencerahill>`_.
 - Raise an exception with an informative message if
   ``submit_mult_calcs`` (and thus the main script) generates zero
   calculations, which can happen if one of the parameters is
@@ -72,6 +88,13 @@ Enhancements
 Bug Fixes
 ~~~~~~~~~
 
+- Use the new ``Longitude`` class to support any longitude numbering
+  convention (e.g. -180 to 180, 0 to 360, or any other) for both
+  defining ``Region`` objects and for input data to be masked.  Fixes
+  bug wherein a region could be silently partially clipped off when
+  masking input data with longitudes of a different numbering
+  convention.  Fixes :issue:`229` via :pull:`266`.  By `Spencer Hill
+  <https://github.com/spencerahill>`_.
 - Cast input DataArrays with datatype ``np.float32`` to ``np.float64``
   as a workaround for incorrectly computed means on float32 arrays in
   bottleneck (see `pydata/xarray#1346


Test patch:
diff --git a/aospy/test/data/objects/examples.py b/aospy/test/data/objects/examples.py
index 0c2e110..7ddb311 100644
--- a/aospy/test/data/objects/examples.py
+++ b/aospy/test/data/objects/examples.py
@@ -91,14 +91,17 @@ sphum = Var(
 globe = Region(
     name='globe',
     description='Entire globe',
-    lat_bounds=(-90, 90),
-    lon_bounds=(0, 360),
+    west_bound=0,
+    east_bound=360,
+    south_bound=-90,
+    north_bound=90,
     do_land_mask=False
 )
 
 sahel = Region(
     name='sahel',
     description='African Sahel',
-    mask_bounds=[((10, 20), (0, 40)), ((10, 20), (342, 360))],
+    mask_bounds=[(0, 40, 10, 20),
+                 (342, 360, 10, 20)],
     do_land_mask=True
 )
diff --git a/aospy/test/test_calc_basic.py b/aospy/test/test_calc_basic.py
index e4000ed..b36e0aa 100755
--- a/aospy/test/test_calc_basic.py
+++ b/aospy/test/test_calc_basic.py
@@ -57,7 +57,10 @@ class TestCalcBasic(unittest.TestCase):
 
     def tearDown(self):
         for direc in [example_proj.direc_out, example_proj.tar_direc_out]:
-            shutil.rmtree(direc)
+            try:
+                shutil.rmtree(direc)
+            except OSError:
+                pass
 
     def test_annual_mean(self):
         calc = Calc(intvl_out='ann', dtype_out_time='av', **self.test_params)
diff --git a/aospy/test/test_region.py b/aospy/test/test_region.py
index 0fbd962..3d9e0de 100644
--- a/aospy/test/test_region.py
+++ b/aospy/test/test_region.py
@@ -3,13 +3,29 @@ import pytest
 import xarray as xr
 
 from aospy import Region
-from aospy.region import _get_land_mask
-from aospy.internal_names import (LAT_STR, LON_STR,
-                                  SFC_AREA_STR, LAND_MASK_STR)
+from aospy.region import (
+    _get_land_mask,
+    BoundsRect,
+)
+from aospy.internal_names import (
+    LAT_STR,
+    LON_STR,
+    SFC_AREA_STR,
+    LAND_MASK_STR
+)
+from aospy.utils import Longitude
 
 
 @pytest.fixture()
-def data_for_reg_calcs():
+def values_for_reg_arr():
+    return np.array([[-2., 1.],
+                     [np.nan, 5.],
+                     [3., 3.],
+                     [4., 4.2]])
+
+
+@pytest.fixture()
+def data_for_reg_calcs(values_for_reg_arr):
     lat = [-10., 1., 10., 20.]
     lon = [1., 10.]
     sfc_area = [0.5, 1., 0.5, 0.25]
@@ -23,62 +39,160 @@ def data_for_reg_calcs():
     sfc_area, _ = xr.broadcast(sfc_area, lon)
     land_mask, _ = xr.broadcast(land_mask, lon)
 
-    da = xr.DataArray([[2., 2.],
-                       [np.nan, 5.],
-                       [3., 3.],
-                       [4., 4.]], coords=[lat, lon])
-    da[SFC_AREA_STR] = sfc_area
-    da[LAND_MASK_STR] = land_mask
+    da = xr.DataArray(values_for_reg_arr, coords=[lat, lon])
+    da.coords[SFC_AREA_STR] = sfc_area
+    da.coords[LAND_MASK_STR] = land_mask
     return da
 
 
+_alt_names = {LON_STR: 'LONS', LAT_STR: 'LATS', LAND_MASK_STR: 'lm',
+              SFC_AREA_STR: 'AREA'}
+
+
+@pytest.fixture()
+def data_reg_alt_names(data_for_reg_calcs):
+    return data_for_reg_calcs.rename(_alt_names)
+
+
 region_no_land_mask = Region(
     name='test',
     description='Test region with no land mask',
-    lat_bounds=(0., 90.),
-    lon_bounds=(0., 5.),
+    west_bound=0.,
+    east_bound=5,
+    south_bound=0,
+    north_bound=90.,
     do_land_mask=False
 )
 
 
 region_land_mask = Region(
     name='test',
-    description='Test region with no land mask',
-    lat_bounds=(0., 90.),
-    lon_bounds=(0., 5.),
+    description='Test region with land mask',
+    west_bound=0.,
+    east_bound=5,
+    south_bound=0,
+    north_bound=90.,
     do_land_mask=True
 )
 
 
+_expected_mask = [[False, False],
+                  [True, False],
+                  [True, False],
+                  [True, False]]
+
+
+def test_get_land_mask_without_land_mask(data_for_reg_calcs):
+    result = _get_land_mask(data_for_reg_calcs,
+                            region_no_land_mask.do_land_mask)
+    expected = 1
+    assert result == expected
+
+
+def test_get_land_mask_with_land_mask(data_for_reg_calcs):
+    result = _get_land_mask(data_for_reg_calcs, region_land_mask.do_land_mask)
+    expected = data_for_reg_calcs[LAND_MASK_STR]
+    xr.testing.assert_identical(result, expected)
+
+
+def test_get_land_mask_non_aospy_name(data_reg_alt_names):
+    result = _get_land_mask(data_reg_alt_names, region_land_mask.do_land_mask,
+                            land_mask_str=_alt_names[LAND_MASK_STR])
+    expected = data_reg_alt_names[_alt_names[LAND_MASK_STR]]
+    xr.testing.assert_identical(result, expected)
+
+
+def test_region_init():
+    region = Region(
+        name='test',
+        description='region description',
+        west_bound=0.,
+        east_bound=5,
+        south_bound=0,
+        north_bound=90.,
+        do_land_mask=True
+    )
+    assert region.name == 'test'
+    assert region.description == 'region description'
+    assert isinstance(region.mask_bounds, tuple)
+    assert len(region.mask_bounds) == 1
+    assert isinstance(region.mask_bounds[0], BoundsRect)
+    assert np.all(region.mask_bounds[0] ==
+                  (Longitude(0.), Longitude(5), 0, 90.))
+    assert region.do_land_mask is True
+
+
+def test_region_init_mult_rect():
+    bounds_in = [[1, 2, 3, 4], (-12, -30, 2.3, 9)]
+    region = Region(name='test', mask_bounds=bounds_in)
+    assert isinstance(region.mask_bounds, tuple)
+    assert len(region.mask_bounds) == 2
+    for (w, e, s, n), bounds in zip(bounds_in, region.mask_bounds):
+        assert isinstance(bounds, tuple)
+        assert np.all(bounds == (Longitude(w), Longitude(e), s, n))
+
+
+def test_region_init_bad_bounds():
+    with pytest.raises(ValueError):
+        Region(mask_bounds=[(1, 2, 3)])
+        Region(mask_bounds=[(1, 2, 3, 4),
+                            (1, 2, 3)])
+
+
+def test_make_mask_single_rect(data_for_reg_calcs):
+    result = region_land_mask._make_mask(data_for_reg_calcs)
+    expected = xr.DataArray(_expected_mask, dims=[LAT_STR, LON_STR],
+                            coords={LAT_STR: data_for_reg_calcs[LAT_STR],
+                                    LON_STR: data_for_reg_calcs[LON_STR]})
+    xr.testing.assert_equal(result.transpose(), expected)
+
+
+def test_make_mask_mult_rect(data_for_reg_calcs):
+    mask_bounds = (region_land_mask.mask_bounds[0], [0, 360, -20, -5])
+    region = Region(name='mult_rect', mask_bounds=mask_bounds)
+    result = region._make_mask(data_for_reg_calcs)
+    expected_values = [[True, True],
+                       [True, False],
+                       [True, False],
+                       [True, False]]
+    expected = xr.DataArray(expected_values, dims=[LAT_STR, LON_STR],
+                            coords={LAT_STR: data_for_reg_calcs[LAT_STR],
+                                    LON_STR: data_for_reg_calcs[LON_STR]})
+    xr.testing.assert_equal(result.transpose(), expected)
+
+
 @pytest.mark.parametrize(
     'region',
     [region_no_land_mask, region_land_mask],
     ids=['no-land-mask', 'land-mask'])
 def test_mask_var(data_for_reg_calcs, region):
-    # Test region masks first row and second column
-    # of test data.  Note that first element of
-    # second row is np.nan in initial dataset.
+    # Test region masks first row and second column of test data.  Note that
+    # first element of second row is np.nan in initial dataset.
     expected_data = [[np.nan, np.nan],
                      [np.nan, np.nan],
                      [3., np.nan],
                      [4., np.nan]]
     expected = data_for_reg_calcs.copy(deep=True)
     expected.values = expected_data
-
     result = region.mask_var(data_for_reg_calcs)
     xr.testing.assert_identical(result, expected)
 
 
-def test_get_land_mask_without_land_mask(data_for_reg_calcs):
-    result = _get_land_mask(data_for_reg_calcs,
-                            region_no_land_mask.do_land_mask)
-    expected = 1
-    assert result == expected
-
-
-def test_get_land_mask_with_land_mask(data_for_reg_calcs):
-    result = _get_land_mask(data_for_reg_calcs, region_land_mask.do_land_mask)
-    expected = data_for_reg_calcs[LAND_MASK_STR]
+@pytest.mark.parametrize(
+    'region',
+    [region_no_land_mask, region_land_mask],
+    ids=['no-land-mask', 'land-mask'])
+def test_mask_var_non_aospy_names(data_reg_alt_names, region):
+    # Test region masks first row and second column of test data.  Note that
+    # first element of second row is np.nan in initial dataset.
+    expected_data = [[np.nan, np.nan],
+                     [np.nan, np.nan],
+                     [3., np.nan],
+                     [4., np.nan]]
+    expected = data_reg_alt_names.copy(deep=True)
+    expected.values = expected_data
+    result = region.mask_var(data_reg_alt_names, lon_str=_alt_names[LON_STR],
+                             lat_str=_alt_names[LAT_STR])
     xr.testing.assert_identical(result, expected)
 
 
@@ -97,3 +211,15 @@ def test_ts_land_mask(data_for_reg_calcs):
     result = region_land_mask.ts(data_for_reg_calcs)
     expected = xr.DataArray(data_for_reg_calcs.values[3, 0])
     xr.testing.assert_identical(result, expected)
+
+
+_map_to_alt_names = {'lon_str': _alt_names[LON_STR],
+                     'lat_str': _alt_names[LAT_STR],
+                     'land_mask_str': _alt_names[LAND_MASK_STR],
+                     'sfc_area_str': _alt_names[SFC_AREA_STR]}
+
+
+def test_ts_non_aospy_names(data_reg_alt_names):
+    result = region_land_mask.ts(data_reg_alt_names, **_map_to_alt_names)
+    expected = xr.DataArray(data_reg_alt_names.values[3, 0])
+    xr.testing.assert_identical(result, expected)
diff --git a/aospy/test/test_utils_longitude.py b/aospy/test/test_utils_longitude.py
new file mode 100644
index 0000000..1dc254e
--- /dev/null
+++ b/aospy/test/test_utils_longitude.py
@@ -0,0 +1,178 @@
+#!/usr/bin/env python
+import numpy as np
+import pytest
+import xarray as xr
+
+from aospy.utils.longitude import Longitude, _maybe_cast_to_lon
+
+
+_good_init_vals_attrs_objs = {
+    -10: [10, 'W', Longitude('10W')],
+    190.2: [169.8, 'W', Longitude('169.8W')],
+    25: [25, 'E', Longitude('25E')],
+    365: [5, 'E', Longitude('5E')],
+    '45.5e': [45.5, 'E', Longitude('45.5E')],
+    '22.2w': [22.2, 'W', Longitude('22.2W')],
+    '0': [0, 'E', Longitude('0E')],
+    }
+
+
+_bad_init_vals = ['10ee', '-20e', '190w', None, 'abc', {'a': 1}]
+
+
+@pytest.mark.parametrize(('val', 'attrs_and_obj'),
+                         zip(_good_init_vals_attrs_objs.keys(),
+                             _good_init_vals_attrs_objs.values()))
+def test_longitude_init_good(val, attrs_and_obj):
+    obj = Longitude(val)
+    expected_lon = attrs_and_obj[0]
+    expected_hem = attrs_and_obj[1]
+    expected_obj = attrs_and_obj[2]
+    assert obj.longitude == expected_lon
+    assert obj.hemisphere == expected_hem
+    assert Longitude(val) == expected_obj
+
+
+def test_longitude_properties():
+    lon = Longitude(5)
+    with pytest.raises(ValueError):
+        lon.longitude = 10
+        lon.hemisphere = 'W'
+
+
+@pytest.mark.parametrize(
+    ('obj', 'expected_val'),
+    [(Longitude('10w'), "Longitude('10.0W')"),
+     (Longitude(0), "Longitude('0.0E')"),
+     (Longitude(180), "Longitude('180.0W')")])
+def test_longitude_repr(obj, expected_val):
+    assert obj.__repr__() == expected_val
+
+
+@pytest.mark.parametrize('bad_val', _bad_init_vals)
+def test_longitude_init_bad(bad_val):
+    with pytest.raises(ValueError):
+        Longitude(bad_val)
+
+
+@pytest.mark.parametrize('val', _good_init_vals_attrs_objs.keys())
+def test_maybe_cast_to_lon_good(val):
+    assert isinstance(_maybe_cast_to_lon(val), Longitude)
+
+
+@pytest.mark.parametrize('bad_val', _bad_init_vals)
+def test_maybe_cast_to_lon_bad(bad_val):
+    assert isinstance(_maybe_cast_to_lon(bad_val), type(bad_val))
+    with pytest.raises((ValueError, TypeError)):
+        _maybe_cast_to_lon(bad_val, strict=True)
+
+
+@pytest.mark.parametrize(
+    ('obj1', 'obj2'),
+    [(Longitude('100W'), Longitude('100W')),
+     (Longitude('90E'), Longitude('90E')),
+     (Longitude(0), Longitude(0)),
+     (Longitude('0E'), 0),
+     (Longitude('0E'), 720),
+     (Longitude('0E'), [0, 720]),
+     (Longitude('0E'), xr.DataArray([0, 720]))])
+def test_lon_eq(obj1, obj2):
+    assert np.all(obj1 == obj2)
+    assert np.all(obj2 == obj1)
+
+
+@pytest.mark.parametrize(
+    ('obj1', 'obj2'),
+    [(Longitude('100W'), Longitude('90W')),
+     (Longitude('90E'), Longitude('100E')),
+     (Longitude('10W'), Longitude('0E')),
+     (Longitude('0E'), 10),
+     (Longitude('0E'), [5, 10]),
+     (Longitude('0E'), xr.DataArray([5, 10]))])
+def test_lon_lt(obj1, obj2):
+    assert np.all(obj1 < obj2)
+    assert np.all(obj2 > obj1)
+
+
+@pytest.mark.parametrize(
+    ('obj1', 'obj2'),
+    [(Longitude('90W'), Longitude('100W')),
+     (Longitude('100E'), Longitude('90E')),
+     (Longitude('0E'), Longitude('10W')),
+     (Longitude('0E'), -10),
+     (Longitude('0E'), [-10, -5]),
+     (Longitude('0E'), xr.DataArray([-10, -5]))])
+def test_lon_gt(obj1, obj2):
+    assert np.all(obj1 > obj2)
+    assert np.all(obj2 < obj1)
+
+
+@pytest.mark.parametrize(
+    ('obj1', 'obj2'),
+    [(Longitude('100W'), Longitude('100W')),
+     (Longitude('90E'), Longitude('90E')),
+     (Longitude(0), Longitude(0)),
+     (Longitude('100W'), Longitude('90W')),
+     (Longitude('90E'), Longitude('100E')),
+     (Longitude('10W'), Longitude('0E')),
+     (Longitude('0E'), 10),
+     (Longitude('0E'), [10, 0]),
+     (Longitude('0E'), xr.DataArray([10, 0]))])
+def test_lon_leq(obj1, obj2):
+    assert np.all(obj1 <= obj2)
+    assert np.all(obj2 >= obj2)
+
+
+@pytest.mark.parametrize(
+    ('obj1', 'obj2'),
+    [(Longitude('100W'), Longitude('100W')),
+     (Longitude('90E'), Longitude('90E')),
+     (Longitude(0), Longitude(0)),
+     (Longitude('90W'), Longitude('100W')),
+     (Longitude('100E'), Longitude('90E')),
+     (Longitude('0E'), Longitude('10W')),
+     (Longitude('0E'), -10),
+     (Longitude('0E'), [0, -10]),
+     (Longitude('0E'), xr.DataArray([0, -10]))])
+def test_lon_geq(obj1, obj2):
+    assert np.all(obj1 >= obj2)
+    assert np.all(obj2 <= obj1)
+
+
+@pytest.mark.parametrize(
+    ('obj', 'expected_val'),
+    [(Longitude('100W'), 260),
+     (Longitude(0), 0),
+     (Longitude('20E'), 20)])
+def test_to_0360(obj, expected_val):
+    assert obj.to_0360() == expected_val
+
+
+@pytest.mark.parametrize(
+    ('obj', 'expected_val'),
+    [(Longitude('100W'), -100),
+     (Longitude(0), 0),
+     (Longitude('20E'), 20)])
+def test_to_pm180(obj, expected_val):
+    assert obj.to_pm180() == expected_val
+
+
+@pytest.mark.parametrize(
+    ('obj1', 'obj2', 'expected_val'),
+    [(Longitude(1), Longitude(1), Longitude(2)),
+     (Longitude(175), Longitude(10), Longitude('175W'))])
+def test_lon_add(obj1, obj2, expected_val):
+    assert obj1 + obj2 == expected_val
+
+
+@pytest.mark.parametrize(
+    ('obj1', 'obj2', 'expected_val'),
+    [(Longitude(1), Longitude(1), Longitude(0)),
+     (Longitude(185), Longitude(10), Longitude('175E')),
+     (Longitude(370), Longitude(20), Longitude('10W'))])
+def test_lon_sub(obj1, obj2, expected_val):
+    assert obj1 - obj2 == expected_val
+
+
+if __name__ == '__main__':
+    pass