Repository: pvlib/pvlib-python
Pull request #1254 (2021-07-05 22:04:29)

Add retrieval function for BSRN data
<!-- Thank you for your contribution! The following items must be addressed before the code can be merged. Please don't hesitate to ask for help if you're unsure of how to accomplish any of the items. Feel free to remove checklist items that are not relevant to your change. -->

 - [x] I am familiar with the [contributing guidelines](https://pvlib-python.readthedocs.io/en/latest/contributing.html)
 - [x] Tests added
 - [x] Updates entries to [`docs/sphinx/source/api.rst`](https://github.com/pvlib/pvlib-python/blob/master/docs/sphinx/source/api.rst) for API changes.
 - [x] Adds description and name entries in the appropriate "what's new" file in [`docs/sphinx/source/whatsnew`](https://github.com/pvlib/pvlib-python/tree/master/docs/sphinx/source/whatsnew) for all changes. Includes link to the GitHub Issue with `` :issue:`num` `` or this Pull Request with `` :pull:`num` ``. Includes contributor name and/or GitHub username (link with `` :ghuser:`user` ``).
 - [x] New code is fully documented. Includes [numpydoc](https://numpydoc.readthedocs.io/en/latest/format.html) compliant docstrings, examples, and comments where necessary.
 - [x] Pull request is nearly complete and ready for detailed review.
 - [x] Maintainer: Appropriate GitHub Labels and Milestone are assigned to the Pull Request and linked Issue.

Add a retrieval function for BSRN data `pvlib.iotools.get_bsrn`. Data is obtained from the BSRN's FTP server.


AdamRJensen: @kanderso-nrel This is my initial idea for the `get_bsrn` function. 

The `read_bsrn` function needs to be updated to take a file buffer or a `parse_bsrn` function needs to be created.
wholmgren: @AdamRJensen our [solarforecastarbiter-core adaptation](https://github.com/SolarArbiter/solarforecastarbiter-core/blob/master/solarforecastarbiter/io/fetch/bsrn.py) of the BSRN code that you contributed to pvlib includes a change to make a `parse_bsrn` function that only takes a file buffer. Maybe that would be helpful to copy back over here.
AdamRJensen: @kanderso-nrel Any idea of why the Linux versions fail, but the others pass?

The log states that the test `test_get_bsrn_no_files` is failing on Linux due to "Failed: DID NOT WARN. No warnings of type (<class 'UserWarning'>,) was emitted." 

My knowledge of Linux is too limited to understand why it would fail on Linux and not the others.. any ideas?
kanderso-nrel: > @kanderso-nrel Any idea of why the Linux versions fail, but the others pass?

I don't think it's actually a linux-specific issue, it's just that only the `conda_linux` versions are configured to run the `remote_data` tests.  I can recreate the CI failure on a local ubuntu VM.  The problem is that it actually does find a file for the bogus date -- I get back data for the month of `2000-06`.  I guess the issue is that files are named with the format `f"{station}%m%y.dat.gz"`, so year 1800 gets formatted as year 00 and interpreted as year 2000.  Changing the `start` and `end` from 1800 to 1899 gets the test to pass for me. 
AdamRJensen: @cwhanse Any opinion on adding **"gri" (ground-reflected irradiance)** to the pvlib list of variables? Better alternatives for the naming of *upward short-wave reflected irradiance*?
AdamRJensen: Thanks for the review @kanderso-nrel! Do we need a second review on this?
kanderso-nrel: > Do we need a second review on this?

My answer to that question is always yes :) https://en.wikipedia.org/wiki/Linus%27s_law
cwhanse: > LGTM, @pvlib/pvlib-maintainer anyone else want to review this?

I don't see how I will make time for it. I vote merge and improve later, if needed.

Patch:
diff --git a/ci/azure/conda_linux.yml b/ci/azure/conda_linux.yml
index 583ac71..3bf8215 100644
--- a/ci/azure/conda_linux.yml
+++ b/ci/azure/conda_linux.yml
@@ -38,6 +38,8 @@ jobs:
   - script: |
       source activate test_env
       export NREL_API_KEY=$(nrelApiKey)
+      export BSRN_FTP_USERNAME=$(BSRN_FTP_USERNAME)
+      export BSRN_FTP_PASSWORD=$(BSRN_FTP_PASSWORD)
       pytest pvlib --remote-data --junitxml=junit/test-results.xml --cov --cov-report=xml --cov-report=html
     displayName: 'pytest'
   - task: PublishTestResults@2
diff --git a/docs/sphinx/source/api.rst b/docs/sphinx/source/api.rst
index 4bd52c0..6d08e74 100644
--- a/docs/sphinx/source/api.rst
+++ b/docs/sphinx/source/api.rst
@@ -13,7 +13,8 @@ object-oriented programming. These classes can help users keep track of
 data in a more organized way, and can help to simplify the modeling
 process. The classes do not add any functionality beyond the procedural
 code. Most of the object methods are simple wrappers around the
-corresponding procedural code.
+corresponding procedural code. For examples of using these classes, see
+the :ref:`pvsystemdoc` and :ref:`modelchaindoc` pages.
 
 .. autosummary::
    :toctree: generated/
@@ -21,6 +22,8 @@ corresponding procedural code.
    location.Location
    pvsystem.PVSystem
    pvsystem.Array
+   pvsystem.FixedMount
+   pvsystem.SingleAxisTrackerMount
    tracking.SingleAxisTracker
    modelchain.ModelChain
    modelchain.ModelChainResult
@@ -488,7 +491,9 @@ of sources and file formats relevant to solar energy modeling.
    iotools.read_pvgis_tmy
    iotools.get_pvgis_hourly
    iotools.read_pvgis_hourly
+   iotools.get_bsrn
    iotools.read_bsrn
+   iotools.parse_bsrn
    iotools.get_cams
    iotools.read_cams
    iotools.parse_cams
diff --git a/docs/sphinx/source/modelchain.rst b/docs/sphinx/source/modelchain.rst
index 00b1d2c..8c7c874 100644
--- a/docs/sphinx/source/modelchain.rst
+++ b/docs/sphinx/source/modelchain.rst
@@ -1,3 +1,4 @@
+.. _modelchaindoc:
 
 ModelChain
 ==========
@@ -40,7 +41,7 @@ objects, module data, and inverter data.
     # pvlib imports
     import pvlib
 
-    from pvlib.pvsystem import PVSystem
+    from pvlib.pvsystem import PVSystem, FixedMount
     from pvlib.location import Location
     from pvlib.modelchain import ModelChain
     from pvlib.temperature import TEMPERATURE_MODEL_PARAMETERS
@@ -53,8 +54,8 @@ objects, module data, and inverter data.
     sandia_module = sandia_modules['Canadian_Solar_CS5P_220M___2009_']
     cec_inverter = cec_inverters['ABB__MICRO_0_25_I_OUTD_US_208__208V_']
 
-Now we create a Location object, a PVSystem object, and a ModelChain
-object.
+Now we create a Location object, a Mount object, a PVSystem object, and a
+ModelChain object.
 
 .. ipython:: python
 
@@ -448,11 +449,11 @@ are in the same order as in ``PVSystem.arrays``.
     location = Location(latitude=32.2, longitude=-110.9)
     inverter_parameters = {'pdc0': 10000, 'eta_inv_nom': 0.96}
     module_parameters = {'pdc0': 250, 'gamma_pdc': -0.004}
-    array_one = Array(surface_tilt=20, surface_azimuth=200,
+    array_one = Array(mount=FixedMount(surface_tilt=20, surface_azimuth=200),
                       module_parameters=module_parameters,
                       temperature_model_parameters=temperature_model_parameters,
                       modules_per_string=10, strings=2)
-    array_two = Array(surface_tilt=20, surface_azimuth=160,
+    array_two = Array(mount=FixedMount(surface_tilt=20, surface_azimuth=160),
                       module_parameters=module_parameters,
                       temperature_model_parameters=temperature_model_parameters,
                       modules_per_string=10, strings=2)
diff --git a/docs/sphinx/source/pvsystem.rst b/docs/sphinx/source/pvsystem.rst
index 25b9a1c..221b78d 100644
--- a/docs/sphinx/source/pvsystem.rst
+++ b/docs/sphinx/source/pvsystem.rst
@@ -138,8 +138,9 @@ provided for each array, and the arrays are provided to
 .. ipython:: python
 
     module_parameters = {'pdc0': 5000, 'gamma_pdc': -0.004}
-    array_one = pvsystem.Array(module_parameters=module_parameters)
-    array_two = pvsystem.Array(module_parameters=module_parameters)
+    mount = pvsystem.FixedMount(surface_tilt=20, surface_azimuth=180)
+    array_one = pvsystem.Array(mount=mount, module_parameters=module_parameters)
+    array_two = pvsystem.Array(mount=mount, module_parameters=module_parameters)
     system_two_arrays = pvsystem.PVSystem(arrays=[array_one, array_two],
                                           inverter_parameters=inverter_parameters)
     print([array.module_parameters for array in system_two_arrays.arrays])
@@ -148,7 +149,7 @@ provided for each array, and the arrays are provided to
 
 The :py:class:`~pvlib.pvsystem.Array` class includes those 
 :py:class:`~pvlib.pvsystem.PVSystem` attributes that may vary from array
-to array. These attributes include `surface_tilt`, `surface_azimuth`,
+to array. These attributes include
 `module_parameters`, `temperature_model_parameters`, `modules_per_string`,
 `strings_per_inverter`, `albedo`, `surface_type`, `module_type`, and
 `racking_model`.
@@ -179,27 +180,30 @@ Tilt and azimuth
 The first parameters which describe the DC part of a PV system are the tilt
 and azimuth of the modules. In the case of a PV system with a single array,
 these parameters can be specified using the `PVSystem.surface_tilt` and
-`PVSystem.surface_azimuth` attributes.
+`PVSystem.surface_azimuth` attributes.  This will automatically create
+an :py:class:`~pvlib.pvsystem.Array` with a :py:class:`~pvlib.pvsystem.FixedMount`
+at the specified tilt and azimuth:
 
 .. ipython:: python
 
     # single south-facing array at 20 deg tilt
     system_one_array = pvsystem.PVSystem(surface_tilt=20, surface_azimuth=180)
-    print(system_one_array.arrays[0].surface_tilt,
-          system_one_array.arrays[0].surface_azimuth)
+    print(system_one_array.arrays[0].mount)
 
 
 In the case of a PV system with several arrays, the parameters are specified
-for each array using the attributes `Array.surface_tilt` and `Array.surface_azimuth`.
+for each array by passing a different :py:class:`~pvlib.pvsystem.FixedMount`
+(or another `Mount` class):
 
 .. ipython:: python
 
-    array_one = pvsystem.Array(surface_tilt=30, surface_azimuth=90)
-    print(array_one.surface_tilt, array_one.surface_azimuth)
-    array_two = pvsystem.Array(surface_tilt=30, surface_azimuth=220)
+    array_one = pvsystem.Array(pvsystem.FixedMount(surface_tilt=30, surface_azimuth=90))
+    print(array_one.mount.surface_tilt, array_one.mount.surface_azimuth)
+    array_two = pvsystem.Array(pvsystem.FixedMount(surface_tilt=30, surface_azimuth=220))
     system = pvsystem.PVSystem(arrays=[array_one, array_two])
     system.num_arrays
-    [(array.surface_tilt, array.surface_azimuth) for array in system.arrays]
+    for array in system.arrays:
+        print(array.mount)
 
 
 The `surface_tilt` and `surface_azimuth` attributes are used in PVSystem
@@ -215,8 +219,7 @@ and `solar_azimuth` as arguments.
 
     # single south-facing array at 20 deg tilt
     system_one_array = pvsystem.PVSystem(surface_tilt=20, surface_azimuth=180)
-    print(system_one_array.arrays[0].surface_tilt,
-          system_one_array.arrays[0].surface_azimuth)
+    print(system_one_array.arrays[0].mount)
 
     # call get_aoi with solar_zenith, solar_azimuth
     aoi = system_one_array.get_aoi(solar_zenith=30, solar_azimuth=180)
@@ -229,7 +232,7 @@ operates in a similar manner.
 .. ipython:: python
 
     # two arrays each at 30 deg tilt with different facing
-    array_one = pvsystem.Array(surface_tilt=30, surface_azimuth=90)
+    array_one = pvsystem.Array(pvsystem.FixedMount(surface_tilt=30, surface_azimuth=90))
     array_one_aoi = array_one.get_aoi(solar_zenith=30, solar_azimuth=180)
     print(array_one_aoi)
 
@@ -240,7 +243,7 @@ operates on all `Array` instances in the `PVSystem`, whereas the the
 
 .. ipython:: python
 
-    array_two = pvsystem.Array(surface_tilt=30, surface_azimuth=220)
+    array_two = pvsystem.Array(pvsystem.FixedMount(surface_tilt=30, surface_azimuth=220))
     system_multiarray = pvsystem.PVSystem(arrays=[array_one, array_two])
     print(system_multiarray.num_arrays)
     # call get_aoi with solar_zenith, solar_azimuth
@@ -315,8 +318,8 @@ Losses
 
 The `losses_parameters` attribute contains data that may be used with
 methods that calculate system losses. At present, these methods include
-only :py:meth:`PVSystem.pvwatts_losses` and
-:py:func:`pvsystem.pvwatts_losses`, but we hope to add more related functions
+only :py:meth:`pvlib.pvsystem.PVSystem.pvwatts_losses` and
+:py:func:`pvlib.pvsystem.pvwatts_losses`, but we hope to add more related functions
 and methods in the future.
 
 
diff --git a/docs/sphinx/source/whatsnew/v0.9.0.rst b/docs/sphinx/source/whatsnew/v0.9.0.rst
index d7286a6..fa7ed68 100644
--- a/docs/sphinx/source/whatsnew/v0.9.0.rst
+++ b/docs/sphinx/source/whatsnew/v0.9.0.rst
@@ -100,6 +100,10 @@ Deprecations
   * ``PVSystem.surface_azimuth``
   * ``PVSystem.temperature_model_parameters``
 
+* The :py:class:`pvlib.tracking.SingleAxisTracker` class is deprecated and
+  replaced by using :py:class:`pvlib.pvsystem.PVSystem` with the new
+  :py:class:`pvlib.pvsystem.SingleAxisTrackerMount` (:pull:`1176`)
+
 
 Enhancements
 ~~~~~~~~~~~~
@@ -107,8 +111,9 @@ Enhancements
   :func:`~pvlib.iotools.get_pvgis_hourly` for reading and retrieving hourly
   solar radiation data and PV power output from PVGIS. (:pull:`1186`,
   :issue:`849`)
-* Add :func:`~pvlib.iotools.read_bsrn` for reading BSRN solar radiation data
-  files. (:pull:`1145`, :issue:`1015`)
+* Add :func:`~pvlib.iotools.get_bsrn` and :func:`~pvlib.iotools.read_bsrn`
+  for retrieving and reading BSRN solar radiation data files.
+  (:pull:`1254`, :pull:`1145`, :issue:`1015`)
 * Add :func:`~pvlib.iotools.get_cams`,
   :func:`~pvlib.iotools.parse_cams`, and 
   :func:`~pvlib.iotools.read_cams`
@@ -120,6 +125,9 @@ Enhancements
 * Added :py:class:`~pvlib.pvsystem.Array` class to represent an array of
   modules separately from a :py:class:`~pvlib.pvsystem.PVSystem`.
   (:pull:`1076`, :issue:`1067`)
+* Added :py:class:`~pvlib.pvsystem.FixedMount` and
+  :py:class:`~pvlib.pvsystem.SingleAxisTrackerMount` classes to use with
+  the new :py:class:`~pvlib.pvsystem.Array` class (:pull:`1176`)
 * Added capability for modeling a PV system with multiple arrays in
   :py:class:`~pvlib.pvsystem.PVSystem`. Updates the ``PVSystem`` API
   to operate on and return tuples where each element of the tuple corresponds
@@ -179,7 +187,8 @@ Bug fixes
 * Corrected an error affecting :py:func:`~pvlib.clearsky.detect_clearsky`
   when data time step is not one minute. Error was introduced in v0.8.1.
   (:issue:`1241`, :pull:`1242`)
-* Corrected :py:func:`~pvlib.solarposition.spa_python` to avoid a future warning. (:pull:`1256`)
+* Changed deprecated use of ``.astype()`` to ``.view()`` in :py:mod:`~pvlib.solarposition`.
+  (:pull:`1256`, :issue:`1261`, :pull:`1262`)
 
 Testing
 ~~~~~~~
@@ -218,3 +227,4 @@ Contributors
 * Joe Ranalli (:ghuser:`jranalli`)
 * Chas Schweizer (:ghuser:`cpr-chas`)
 * Yoann Louvet (:ghuser:`YoannUniKS`)
+* Brandon Carpenter (:ghuser:`hashstat`)
diff --git a/pvlib/data/variables_style_rules.csv b/pvlib/data/variables_style_rules.csv
index 3419062..a56dddd 100644
--- a/pvlib/data/variables_style_rules.csv
+++ b/pvlib/data/variables_style_rules.csv
@@ -7,6 +7,7 @@ dni_extra;direct normal irradiance at top of atmosphere (extraterrestrial)
 dhi;diffuse horizontal irradiance
 bhi;beam/direct horizontal irradiance
 ghi;global horizontal irradiance
+gri;ground-reflected irradiance
 aoi;angle of incidence between :math:`90\deg` and :math:`90\deg`
 aoi_projection;cos(aoi)
 airmass;airmass
diff --git a/pvlib/iotools/__init__.py b/pvlib/iotools/__init__.py
index 69786f8..b02ce24 100644
--- a/pvlib/iotools/__init__.py
+++ b/pvlib/iotools/__init__.py
@@ -15,7 +15,9 @@ from pvlib.iotools.psm3 import parse_psm3  # noqa: F401
 from pvlib.iotools.pvgis import get_pvgis_tmy, read_pvgis_tmy  # noqa: F401
 from pvlib.iotools.pvgis import read_pvgis_hourly  # noqa: F401
 from pvlib.iotools.pvgis import get_pvgis_hourly  # noqa: F401
+from pvlib.iotools.bsrn import get_bsrn  # noqa: F401
 from pvlib.iotools.bsrn import read_bsrn  # noqa: F401
+from pvlib.iotools.bsrn import parse_bsrn  # noqa: F401
 from pvlib.iotools.sodapro import get_cams  # noqa: F401
 from pvlib.iotools.sodapro import read_cams  # noqa: F401
 from pvlib.iotools.sodapro import parse_cams  # noqa: F401
diff --git a/pvlib/iotools/bsrn.py b/pvlib/iotools/bsrn.py
index 0d3b31e..c9c3abb 100644
--- a/pvlib/iotools/bsrn.py
+++ b/pvlib/iotools/bsrn.py
@@ -4,139 +4,455 @@
 
 import pandas as pd
 import gzip
+import ftplib
+import warnings
+import io
+import os
 
-COL_SPECS = [(0, 3), (4, 9), (10, 16), (16, 22), (22, 27), (27, 32), (32, 39),
-             (39, 45), (45, 50), (50, 55), (55, 64), (64, 70), (70, 75)]
+BSRN_FTP_URL = "ftp.bsrn.awi.de"
 
-BSRN_COLUMNS = ['day', 'minute',
-                'ghi', 'ghi_std', 'ghi_min', 'ghi_max',
-                'dni', 'dni_std', 'dni_min', 'dni_max',
-                'empty', 'empty', 'empty', 'empty', 'empty',
-                'dhi', 'dhi_std', 'dhi_min', 'dhi_max',
-                'lwd', 'lwd_std', 'lwd_min', 'lwd_max',
-                'temp_air', 'relative_humidity', 'pressure']
+BSRN_LR0100_COL_SPECS = [(0, 3), (4, 9), (10, 16), (16, 22), (22, 27),
+                         (27, 32), (32, 39), (39, 45), (45, 50), (50, 55),
+                         (55, 64), (64, 70), (70, 75)]
 
+BSRN_LR0300_COL_SPECS = [(1, 3), (4, 9), (10, 16), (16, 22), (22, 27),
+                         (27, 31), (31, 38), (38, 44), (44, 49), (49, 54),
+                         (54, 61), (61, 67), (67, 72), (72, 78)]
 
-def read_bsrn(filename):
+BSRN_LR0500_COL_SPECS = [(0, 3), (3, 8), (8, 14), (14, 20), (20, 26), (26, 32),
+                         (32, 38), (38, 44), (44, 50), (50, 56), (56, 62),
+                         (62, 68), (68, 74), (74, 80)]
+
+BSRN_LR0100_COLUMNS = ['day', 'minute',
+                       'ghi', 'ghi_std', 'ghi_min', 'ghi_max',
+                       'dni', 'dni_std', 'dni_min', 'dni_max',
+                       'empty', 'empty', 'empty', 'empty', 'empty',
+                       'dhi', 'dhi_std', 'dhi_min', 'dhi_max',
+                       'lwd', 'lwd_std', 'lwd_min', 'lwd_max',
+                       'temp_air', 'relative_humidity', 'pressure']
+
+BSRN_LR0300_COLUMNS = ['day', 'minute', 'gri', 'gri_std', 'gri_min', 'gri_max',
+                       'lwu', 'lwu_std', 'lwu_min', 'lwu_max', 'net_radiation',
+                       'net_radiation_std', 'net_radiation_min',
+                       'net_radiation_max']
+
+BSRN_LR0500_COLUMNS = ['day', 'minute', 'uva_global', 'uva_global_std',
+                       'uva_global_min', 'uva_global_max', 'uvb_direct',
+                       'uvb_direct_std', 'uvb_direct_min', 'uvb_direct_max',
+                       'empty', 'empty', 'empty', 'empty',
+                       'uvb_global', 'uvb_global_std', 'uvb_global_min',
+                       'uvb_global_max', 'uvb_diffuse', 'uvb_diffuse_std',
+                       'uvb_diffuse', 'uvb_diffuse_std',
+                       'uvb_diffuse_min', 'uvb_diffuse_max',
+                       'uvb_reflected', 'uvb_reflected_std',
+                       'uvb_reflected_min', 'uvb_reflected_max']
+
+BSRN_COLUMNS = {'0100': BSRN_LR0100_COLUMNS, '0300': BSRN_LR0300_COLUMNS,
+                '0500': BSRN_LR0500_COLUMNS}
+
+
+def _empty_dataframe_from_logical_records(logical_records):
+    # Create an empty DataFrame with the column names corresponding to the
+    # requested logical records
+    columns = []
+    for lr in logical_records:
+        columns += BSRN_COLUMNS[lr][2:]
+    return pd.DataFrame(columns=columns)
+
+
+def get_bsrn(start, end, station, username, password,
+             logical_records=('0100',), local_path=None):
     """
-    Read a BSRN station-to-archive file into a DataFrame.
+    Retrieve ground measured irradiance data from the BSRN FTP server.
 
     The BSRN (Baseline Surface Radiation Network) is a world wide network
     of high-quality solar radiation monitoring stations as described in [1]_.
-    The function only parses the basic measurements (LR0100), which include
-    global, diffuse, direct and downwelling long-wave radiation [2]_. Future
-    updates may include parsing of additional data and meta-data.
+    Data is retrieved from the BSRN FTP server [2]_.
+
+    Data is returned for the entire months between and including start and end.
+
+    Parameters
+    ----------
+    start: datetime-like
+        First day of the requested period
+    end: datetime-like
+        Last day of the requested period
+    station: str
+        3-letter BSRN station abbreviation
+    username: str
+        username for accessing the BSRN FTP server
+    password: str
+        password for accessing the BSRN FTP server
+    logical_records: list or tuple, default: ('0100',)
+        List of the logical records (LR) to parse. Options include: '0100',
+        '0300', and '0500'.
+    local_path: str or path-like, optional
+        If specified, path (abs. or relative) of where to save files
+
+    Returns
+    -------
+    data: DataFrame
+        timeseries data from the BSRN archive, see
+        :func:`pvlib.iotools.read_bsrn` for fields. An empty DataFrame is
+        returned if no data was found for the time period.
+    metadata: dict
+        metadata for the last available monthly file.
+
+    Raises
+    ------
+    KeyError
+        If the specified station does not exist on the FTP server.
 
-    BSRN files are freely available and can be accessed via FTP [3]_. Required
+    Warns
+    -----
+    UserWarning
+        If one or more requested files are missing a UserWarning is returned
+        with a list of the filenames missing. If no files match the specified
+        station and timeframe a seperate UserWarning is given.
+
+    Notes
+    -----
+    The username and password for the BSRN FTP server can be obtained for free
+    as described in the BSRN's Data Release Guidelines [3]_.
+
+    Currently only parsing of logical records 0100, 0300 and 0500 is supported.
+    Note not all stations measure LR0300 and LR0500. However, LR0100 is
+    mandatory as it contains the basic irradiance and auxillary measurements.
+    See [4]_ for a description of the different logical records. Future updates
+    may include parsing of additional data and metadata.
+
+    Important
+    ---------
+    While data from the BSRN is generally of high-quality, measurement data
+    should always be quality controlled before usage!
+
+    Examples
+    --------
+    >>> # Retrieve two months irradiance data from the Cabauw BSRN station
+    >>> data, metadata = pvlib.iotools.get_bsrn(  # doctest: +SKIP
+    >>>     start=pd.Timestamp(2020,1,1), end=pd.Timestamp(2020,12,1),   # doctest: +SKIP
+    >>>     station='cab', username='yourusername', password='yourpassword')  # doctest: +SKIP
+
+    See Also
+    --------
+    pvlib.iotools.read_bsrn, pvlib.iotools.parse_bsrn
+
+    References
+    ----------
+    .. [1] `World Radiation Monitoring Center - Baseline Surface Radiation
+        Network (BSRN)
+        <https://bsrn.awi.de/>`_
+    .. [2] `BSRN Data Retrieval via FTP
+       <https://bsrn.awi.de/data/data-retrieval-via-ftp/>`_
+    .. [4] `BSRN Data Release Guidelines
+       <https://bsrn.awi.de/data/conditions-of-data-release/>`_
+    .. [3] `Update of the Technical Plan for BSRN Data Management, 2013,
+       Global Climate Observing System (GCOS) GCOS-174.
+       <https://bsrn.awi.de/fileadmin/user_upload/bsrn.awi.de/Publications/gcos-174.pdf>`_
+    """  # noqa: E501
+    # The FTP server uses lowercase station abbreviations
+    station = station.lower()
 
-    username and password are easily obtainable as described in the BSRN's
-    Data Release Guidelines [4]_.
+    # Generate list files to download based on start/end (SSSMMYY.dat.gz)
+    filenames = pd.date_range(
+        start, end.replace(day=1) + pd.DateOffset(months=1), freq='1M')\
+        .strftime(f"{station}%m%y.dat.gz").tolist()
 
+    # Create FTP connection
+    with ftplib.FTP(BSRN_FTP_URL, username, password) as ftp:
+        # Change to station sub-directory (checks that the station exists)
+        try:
+            ftp.cwd(f'/{station}')
+        except ftplib.error_perm as e:
+            raise KeyError('Station sub-directory does not exist. Specified '
+                           'station is probably not a proper three letter '
+                           'station abbreviation.') from e
+        dfs = []  # Initialize list for monthly dataframes
+        non_existing_files = []  # Initilize list of files that were not found
+        for filename in filenames:
+            try:
+                bio = io.BytesIO()  # Initialize BytesIO object
+                # Retrieve binary file from server and write to BytesIO object
+                response = ftp.retrbinary(f'RETR {filename}', bio.write)
+                # Check that transfer was successfull
+                if not response.startswith('226 Transfer complete'):
+                    raise ftplib.Error(response)
+                # Save file locally if local_path is specified
+                if local_path is not None:
+                    # Create local file
+                    with open(os.path.join(local_path, filename), 'wb') as f:
+                        f.write(bio.getbuffer())  # Write local file
+                # Open gzip file and convert to StringIO
+                bio.seek(0)  # reset buffer to start of file
+                gzip_file = io.TextIOWrapper(gzip.GzipFile(fileobj=bio),
+                                             encoding='latin1')
+                dfi, metadata = parse_bsrn(gzip_file, logical_records)
+                dfs.append(dfi)
+            # FTP client raises an error if the file does not exist on server
+            except ftplib.error_perm as e:
+                if str(e) == '550 Failed to open file.':
+                    non_existing_files.append(filename)
+                else:
+                    raise ftplib.error_perm(e)
+        ftp.quit()  # Close and exit FTP connection
 
+    # Raise user warnings
+    if not dfs:  # If no files were found
+        warnings.warn('No files were available for the specified timeframe.')
+    elif non_existing_files:  # If only some files were missing
+        warnings.warn(f'The following files were not found: {non_existing_files}')  # noqa: E501
+
+    # Concatenate monthly dataframes to one dataframe
+    if len(dfs):
+        data = pd.concat(dfs, axis='rows')
+    else:  # Return empty dataframe
+        data = _empty_dataframe_from_logical_records(logical_records)
+        metadata = {}
+    # Return dataframe and metadata (metadata belongs to last available file)
+    return data, metadata
+
+
+def parse_bsrn(fbuf, logical_records=('0100',)):
+    """
+    Parse a file-like buffer of a BSRN station-to-archive file.
 
     Parameters
     ----------
-    filename: str
-        A relative or absolute file path.
+    fbuf: file-like buffer
+        Buffer of a BSRN station-to-archive data file
+    logical_records: list or tuple, default: ('0100',)
+        List of the logical records (LR) to parse. Options include: '0100',
+        '0300', and '0500'.
 
     Returns
     -------
     data: DataFrame
-        A DataFrame with the columns as described below. For more extensive
-        description of the variables, consult [2]_.
+        timeseries data from the BSRN archive, see
+        :func:`pvlib.iotools.read_bsrn` for fields. An empty DataFrame is
+        returned if the specified logical records were not found.
+    metadata: dict
+        Dictionary containing metadata (primarily from LR0004).
+
+    See Also
+    --------
+    pvlib.iotools.read_bsrn, pvlib.iotools.get_bsrn
+
+    """
+    # Parse metadata
+    fbuf.readline()  # first line should be *U0001, so read it and discard
+    date_line = fbuf.readline()  # second line contains important metadata
+    start_date = pd.Timestamp(year=int(date_line[7:11]),
+                              month=int(date_line[3:6]), day=1,
+                              tz='UTC')  # BSRN timestamps are UTC
+
+    metadata = {}  # Initilize dictionary containing metadata
+    metadata['start date'] = start_date
+    metadata['station identification number'] = int(date_line[:3])
+    metadata['version of data'] = int(date_line.split()[-1])
+    for line in fbuf:
+        if line[2:6] == '0004':  # stop once LR0004 has been reached
+            break
+        elif line == '':
+            raise ValueError('Mandatory record LR0004 not found.')
+    metadata['date when station description changed'] = fbuf.readline().strip()
+    metadata['surface type'] = int(fbuf.readline(3))
+    metadata['topography type'] = int(fbuf.readline())
+    metadata['address'] = fbuf.readline().strip()
+    metadata['telephone no. of station'] = fbuf.readline(20).strip()
+    metadata['FAX no. of station'] = fbuf.readline().strip()
+    metadata['TCP/IP no. of station'] = fbuf.readline(15).strip()
+    metadata['e-mail address of station'] = fbuf.readline().strip()
+    metadata['latitude_bsrn'] = float(fbuf.readline(8))  # BSRN convention
+    metadata['latitude'] = metadata['latitude_bsrn'] - 90  # ISO 19115
+    metadata['longitude_bsrn'] = float(fbuf.readline(8))  # BSRN convention
+    metadata['longitude'] = metadata['longitude_bsrn'] - 180  # ISO 19115
+    metadata['altitude'] = int(fbuf.readline(5))
+    metadata['identification of "SYNOP" station'] = fbuf.readline().strip()
+    metadata['date when horizon changed'] = fbuf.readline().strip()
+    # Pass last section of LR0004 containing the horizon elevation data
+    horizon = []  # list for raw horizon elevation data
+    while True:
+        line = fbuf.readline()
+        if ('*' in line) | (line == ''):
+            break
+        else:
+            horizon += [int(i) for i in line.split()]
+    horizon = pd.Series(horizon[1::2], horizon[::2], name='horizon_elevation',
+                        dtype=int).drop(-1, errors='ignore').sort_index()
+    horizon.index.name = 'azimuth'
+    metadata['horizon'] = horizon
+
+    # Read file and store the starting line number and number of lines for
+    # each logical record (LR)
+    fbuf.seek(0)  # reset buffer to start of file
+    lr_startrow = {}  # Dictionary of starting line number for each LR
+    lr_nrows = {}  # Dictionary of end line number for each LR
+    for num, line in enumerate(fbuf):
+        if line.startswith('*'):  # Find start of all logical records
+            if len(lr_startrow) >= 1:
+                lr_nrows[lr] = num - lr_startrow[lr] - 1  # noqa: F821
+            lr = line[2:6]  # string of 4 digit LR number
+            lr_startrow[lr] = num
+    lr_nrows[lr] = num - lr_startrow[lr]
+
+    for lr in logical_records:
+        if lr not in ['0100', '0300', '0500']:
+            raise ValueError(f"Logical record {lr} not in "
+                             "['0100', '0300','0500'].")
+    dfs = []  # Initialize empty list for dataframe
+
+    # Parse LR0100 - basic measurements including GHI, DNI, DHI and temperature
+    if ('0100' in lr_startrow.keys()) & ('0100' in logical_records):
+        fbuf.seek(0)  # reset buffer to start of file
+        LR_0100 = pd.read_fwf(fbuf, skiprows=lr_startrow['0100'] + 1,
+                              nrows=lr_nrows['0100'], header=None,
+                              colspecs=BSRN_LR0100_COL_SPECS,
+                              na_values=[-999.0, -99.9])
+        # Create multi-index and unstack, resulting in 1 col for each variable
+        LR_0100 = LR_0100.set_index([LR_0100.index // 2, LR_0100.index % 2])
+        LR_0100 = LR_0100.unstack(level=1).swaplevel(i=0, j=1, axis='columns')
+        # Sort columns to match original order and assign column names
+        LR_0100 = LR_0100.reindex(sorted(LR_0100.columns), axis='columns')
+        LR_0100.columns = BSRN_LR0100_COLUMNS
+        # Set datetime index
+        LR_0100.index = (start_date+pd.to_timedelta(LR_0100['day']-1, unit='d')
+                         + pd.to_timedelta(LR_0100['minute'], unit='T'))
+        # Drop empty, minute, and day columns
+        LR_0100 = LR_0100.drop(columns=['empty', 'day', 'minute'])
+        dfs.append(LR_0100)
+
+    # Parse LR0300 - other time series data, including upward and net radiation
+    if ('0300' in lr_startrow.keys()) & ('0300' in logical_records):
+        fbuf.seek(0)  # reset buffer to start of file
+        LR_0300 = pd.read_fwf(fbuf, skiprows=lr_startrow['0300']+1,
+                              nrows=lr_nrows['0300'], header=None,
+                              na_values=[-999.0, -99.9],
+                              colspecs=BSRN_LR0300_COL_SPECS,
+                              names=BSRN_LR0300_COLUMNS)
+        LR_0300.index = (start_date+pd.to_timedelta(LR_0300['day']-1, unit='d')
+                         + pd.to_timedelta(LR_0300['minute'], unit='T'))
+        LR_0300 = LR_0300.drop(columns=['day', 'minute']).astype(float)
+        dfs.append(LR_0300)
+
+    # Parse LR0500 - UV measurements
+    if ('0500' in lr_startrow.keys()) & ('0500' in logical_records):
+        fbuf.seek(0)  # reset buffer to start of file
+        LR_0500 = pd.read_fwf(fbuf, skiprows=lr_startrow['0500']+1,
+                              nrows=lr_nrows['0500'], na_values=[-99.9],
+                              header=None, colspecs=BSRN_LR0500_COL_SPECS)
+        # Create multi-index and unstack, resulting in 1 col for each variable
+        LR_0500 = LR_0500.set_index([LR_0500.index // 2, LR_0500.index % 2])
+        LR_0500 = LR_0500.unstack(level=1).swaplevel(i=0, j=1, axis='columns')
+        # Sort columns to match original order and assign column names
+        LR_0500 = LR_0500.reindex(sorted(LR_0500.columns), axis='columns')
+        LR_0500.columns = BSRN_LR0500_COLUMNS
+        LR_0500.index = (start_date+pd.to_timedelta(LR_0500['day']-1, unit='d')
+                         + pd.to_timedelta(LR_0500['minute'], unit='T'))
+        LR_0500 = LR_0500.drop(columns=['empty', 'day', 'minute'])
+        dfs.append(LR_0500)
+
+    if len(dfs):
+        data = pd.concat(dfs, axis='columns')
+    else:
+        data = _empty_dataframe_from_logical_records(logical_records)
+        metadata = {}
+    return data, metadata
+
+
+def read_bsrn(filename, logical_records=('0100',)):
+    """
+    Read a BSRN station-to-archive file into a DataFrame.
+
+    The BSRN (Baseline Surface Radiation Network) is a world wide network
+    of high-quality solar radiation monitoring stations as described in [1]_.
+    The function is able to parse logical records (LR) 0100, 0300, and 0500.
+    LR0100 contains the basic measurements, which include global, diffuse, and
+    direct irradiance, as well as downwelling long-wave radiation [2]_. Future
+    updates may include parsing of additional data and metadata.
+
+    BSRN files are freely available and can be accessed via FTP [3]_. The
+    username and password for the BSRN FTP server can be obtained for free as
+    described in the BSRN's Data Release Guidelines [3]_.
+
+    Parameters
+    ----------
+    filename: str or path-like
+        Name or path of a BSRN station-to-archive data file
+    logical_records: list or tuple, default: ('0100',)
+        List of the logical records (LR) to parse. Options include: '0100',
+        '0300', and '0500'.
+
+    Returns
+    -------
+    data: DataFrame
+        A DataFrame with the columns as described below. For a more extensive
+        description of the variables, consult [2]_. An empty DataFrame is
+        returned if the specified logical records were not found.
+    metadata: dict
+        Dictionary containing metadata (primarily from LR0004).
 
     Notes
     -----
-    The data DataFrame includes the following fields:
+    The data DataFrame for LR0100 includes the following fields:
 
     =======================  ======  ==========================================
     Key                      Format  Description
     =======================  ======  ==========================================
-    day                      int     Day of the month 1-31
-    minute                   int     Minute of the day 0-1439
-    ghi                      float   Mean global horizontal irradiance [W/m^2]
-    ghi_std                  float   Std. global horizontal irradiance [W/m^2]
-    ghi_min                  float   Min. global horizontal irradiance [W/m^2]
-    ghi_max                  float   Max. global horizontal irradiance [W/m^2]
-    dni                      float   Mean direct normal irradiance [W/m^2]
-    dni_std                  float   Std. direct normal irradiance [W/m^2]
-    dni_min                  float   Min. direct normal irradiance [W/m^2]
-    dni_max                  float   Max. direct normal irradiance [W/m^2]
-    dhi                      float   Mean diffuse horizontal irradiance [W/m^2]
-    dhi_std                  float   Std. diffuse horizontal irradiance [W/m^2]
-    dhi_min                  float   Min. diffuse horizontal irradiance [W/m^2]
-    dhi_max                  float   Max. diffuse horizontal irradiance [W/m^2]
-    lwd                      float   Mean. downward long-wave radiation [W/m^2]
-    lwd_std                  float   Std. downward long-wave radiation [W/m^2]
-    lwd_min                  float   Min. downward long-wave radiation [W/m^2]
-    lwd_max                  float   Max. downward long-wave radiation [W/m^2]
+    **Logical record 0100**
+    ---------------------------------------------------------------------------
+    ghi†                     float   Mean global horizontal irradiance [W/m^2]
+    dni†                     float   Mean direct normal irradiance [W/m^2]
+    dhi†                     float   Mean diffuse horizontal irradiance [W/m^2]
+    lwd†                     float   Mean. downward long-wave radiation [W/m^2]
     temp_air                 float   Air temperature [°C]
     relative_humidity        float   Relative humidity [%]
     pressure                 float   Atmospheric pressure [hPa]
+    -----------------------  ------  ------------------------------------------
+    **Logical record 0300**
+    ---------------------------------------------------------------------------
+    gri†                     float   Mean ground-reflected irradiance [W/m^2]
+    lwu†                     float   Mean long-wave upwelling irradiance [W/m^2]
+    net_radiation†           float   Mean net radiation (net radiometer) [W/m^2]
+    -----------------------  ------  ------------------------------------------
+    **Logical record 0500**
+    ---------------------------------------------------------------------------
+    uva_global†              float   Mean UV-A global irradiance [W/m^2]
+    uvb_direct†              float   Mean UV-B direct irradiance [W/m^2]
+    uvb_global†              float   Mean UV-B global irradiance [W/m^2]
+    uvb_diffuse†             float   Mean UV-B diffuse irradiance [W/m^2]
+    uvb_reflected†           float   Mean UV-B reflected irradiance [W/m^2]
     =======================  ======  ==========================================
 
+    † Marked variables have corresponding columns for the standard deviation
+    (_std), minimum (_min), and maximum (_max) calculated from the 60 samples
+    that are average into each 1-minute measurement.
+
+    Hint
+    ----
+    According to [2]_ "All time labels in the station-to-archive files denote
+    the start of a time interval." This corresponds to left bin edge labeling.
+
+    See Also
+    --------
+    pvlib.iotools.parse_bsrn, pvlib.iotools.get_bsrn
+
     References
     ----------
     .. [1] `World Radiation Monitoring Center - Baseline Surface Radiation
         Network (BSRN)
         <https://bsrn.awi.de/>`_
     .. [2] `Update of the Technical Plan for BSRN Data Management, 2013,
-       Global Climate Observing System (GCOS) GCOS-172.
+       Global Climate Observing System (GCOS) GCOS-174.
        <https://bsrn.awi.de/fileadmin/user_upload/bsrn.awi.de/Publications/gcos-174.pdf>`_
     .. [3] `BSRN Data Retrieval via FTP
        <https://bsrn.awi.de/data/data-retrieval-via-ftp/>`_
     .. [4] `BSRN Data Release Guidelines
        <https://bsrn.awi.de/data/conditions-of-data-release/>`_
-    """
-
-    # Read file and store the starting line number for each logical record (LR)
-    line_no_dict = {}
+    """  # noqa: E501
     if str(filename).endswith('.gz'):  # check if file is a gzipped (.gz) file
         open_func, mode = gzip.open, 'rt'
     else:
         open_func, mode = open, 'r'
     with open_func(filename, mode) as f:
-        f.readline()  # first line should be *U0001, so read it and discard
-        line_no_dict['0001'] = 0
-        date_line = f.readline()  # second line contains the year and month
-        start_date = pd.Timestamp(year=int(date_line[7:11]),
-                                  month=int(date_line[3:6]), day=1,
-                                  tz='UTC')  # BSRN timestamps are UTC
-        for num, line in enumerate(f, start=2):
-            if line.startswith('*'):  # Find start of all logical records
-                line_no_dict[line[2:6]] = num  # key is 4 digit LR number
-
-    # Determine start and end line of logical record LR0100 to be parsed
-    start_row = line_no_dict['0100'] + 1  # Start line number
-    # If LR0100 is the last logical record, then read rest of file
-    if start_row-1 == max(line_no_dict.values()):
-        end_row = num  # then parse rest of the file
-    else:  # otherwise parse until the beginning of the next logical record
-        end_row = min([i for i in line_no_dict.values() if i > start_row]) - 1
-    nrows = end_row-start_row+1
-
-    # Read file as a fixed width file (fwf)
-    data = pd.read_fwf(filename, skiprows=start_row, nrows=nrows, header=None,
-                       colspecs=COL_SPECS, na_values=[-999.0, -99.9],
-                       compression='infer')
-
-    # Create multi-index and unstack, resulting in one column for each variable
-    data = data.set_index([data.index // 2, data.index % 2])
-    data = data.unstack(level=1).swaplevel(i=0, j=1, axis='columns')
-
-    # Sort columns to match original order and assign column names
-    data = data.reindex(sorted(data.columns), axis='columns')
-    data.columns = BSRN_COLUMNS
-    # Drop empty columns
-    data = data.drop('empty', axis='columns')
-
-    # Change day and minute type to integer
-    data['day'] = data['day'].astype('Int64')
-    data['minute'] = data['minute'].astype('Int64')
-
-    # Set datetime index
-    data.index = (start_date
-                  + pd.to_timedelta(data['day']-1, unit='d')
-                  + pd.to_timedelta(data['minute'], unit='T'))
-
-    return data
+        content = parse_bsrn(f, logical_records)
+    return content
diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py
index 93d7331..eb28897 100644
--- a/pvlib/pvsystem.py
+++ b/pvlib/pvsystem.py
@@ -11,6 +11,9 @@ import os
 from urllib.request import urlopen
 import numpy as np
 import pandas as pd
+from dataclasses import dataclass
+from abc import ABC, abstractmethod
+from typing import Optional
 
 from pvlib._deprecation import deprecated
 
@@ -234,8 +237,7 @@ class PVSystem:
                 array_losses_parameters = _build_kwargs(['dc_ohmic_percent'],
                                                         losses_parameters)
             self.arrays = (Array(
-                surface_tilt,
-                surface_azimuth,
+                FixedMount(surface_tilt, surface_azimuth, racking_model),
                 albedo,
                 surface_type,
                 module,
@@ -244,7 +246,6 @@ class PVSystem:
                 temperature_model_parameters,
                 modules_per_string,
                 strings_per_inverter,
-                racking_model,
                 array_losses_parameters,
             ),)
         elif len(arrays) == 0:
@@ -299,7 +300,6 @@ class PVSystem:
 
     @_unwrap_single_value
     def _infer_cell_type(self):
-
         """
         Examines module_parameters and maps the Technology key for the CEC
         database and the Material key for the Sandia database to a common
@@ -814,11 +814,13 @@ class PVSystem:
         The Fuentes thermal model uses the module surface tilt for convection
         modeling. The SAM implementation of PVWatts hardcodes the surface tilt
         value at 30 degrees, ignoring whatever value is used for irradiance
-        transposition. This method defaults to using ``self.surface_tilt``, but
-        if you want to match the PVWatts behavior, you can override it by
-        including a ``surface_tilt`` value in ``temperature_model_parameters``.
+        transposition.  If you want to match the PVWatts behavior you can
+        either leave ``surface_tilt`` unspecified to use the PVWatts default
+        of 30, or specify a ``surface_tilt`` value in the Array's
+        ``temperature_model_parameters``.
 
-        The `temp_air` and `wind_speed` parameters may be passed as tuples
+        The `temp_air`, `wind_speed`, and `surface_tilt` parameters may be
+        passed as tuples
         to provide different values for each Array in the system. If not
         passed as a tuple then the same value is used for input to each Array.
         If passed as a tuple the length must be the same as the number of
@@ -868,7 +870,6 @@ class PVSystem:
 
     @_unwrap_single_value
     def first_solar_spectral_loss(self, pw, airmass_absolute):
-
         """
         Use the :py:func:`first_solar_spectral_correction` function to
         calculate the spectral loss modifier. The model coefficients are
@@ -1166,25 +1167,25 @@ class PVSystem:
     @_unwrap_single_value
     @_check_deprecated_passthrough
     def surface_tilt(self):
-        return tuple(array.surface_tilt for array in self.arrays)
+        return tuple(array.mount.surface_tilt for array in self.arrays)
 
     @surface_tilt.setter
     @_check_deprecated_passthrough
     def surface_tilt(self, value):
         for array in self.arrays:
-            array.surface_tilt = value
+            array.mount.surface_tilt = value
 
     @property
     @_unwrap_single_value
     @_check_deprecated_passthrough
     def surface_azimuth(self):
-        return tuple(array.surface_azimuth for array in self.arrays)
+        return tuple(array.mount.surface_azimuth for array in self.arrays)
 
     @surface_azimuth.setter
     @_check_deprecated_passthrough
     def surface_azimuth(self, value):
         for array in self.arrays:
-            array.surface_azimuth = value
+            array.mount.surface_azimuth = value
 
     @property
     @_unwrap_single_value
@@ -1202,13 +1203,13 @@ class PVSystem:
     @_unwrap_single_value
     @_check_deprecated_passthrough
     def racking_model(self):
-        return tuple(array.racking_model for array in self.arrays)
+        return tuple(array.mount.racking_model for array in self.arrays)
 
     @racking_model.setter
     @_check_deprecated_passthrough
     def racking_model(self, value):
         for array in self.arrays:
-            array.racking_model = value
+            array.mount.racking_model = value
 
     @property
     @_unwrap_single_value
@@ -1244,20 +1245,16 @@ class Array:
     """
     An Array is a set of of modules at the same orientation.
 
-    Specifically, an array is defined by tilt, azimuth, the
+    Specifically, an array is defined by its mount, the
     module parameters, the number of parallel strings of modules
     and the number of modules on each string.
 
     Parameters
     ----------
-    surface_tilt: float or array-like, default 0
-        Surface tilt angles in decimal degrees.
-        The tilt angle is defined as degrees from horizontal
-        (e.g. surface facing up = 0, surface facing horizon = 90)
-
-    surface_azimuth: float or array-like, default 180
-        Azimuth angle of the module surface.
-        North=0, East=90, South=180, West=270.
+    mount: FixedMount, SingleAxisTrackerMount, or other
+        Mounting for the array, either on fixed-tilt racking or horizontal
+        single axis tracker. Mounting is used to determine module orientation.
+        If not provided, a FixedMount with zero tilt is used.
 
     albedo : None or float, default None
         The ground albedo. If ``None``, will attempt to use
@@ -1291,26 +1288,22 @@ class Array:
     strings: int, default 1
         Number of parallel strings in the array.
 
-    racking_model : None or string, default None
-        Valid strings are 'open_rack', 'close_mount', and 'insulated_back'.
-        Used to identify a parameter set for the SAPM cell temperature model.
-
     array_losses_parameters: None, dict or Series, default None.
         Supported keys are 'dc_ohmic_percent'.
 
+    name: None or str, default None
+        Name of Array instance.
     """
 
-    def __init__(self,
-                 surface_tilt=0, surface_azimuth=180,
+    def __init__(self, mount,
                  albedo=None, surface_type=None,
                  module=None, module_type=None,
                  module_parameters=None,
                  temperature_model_parameters=None,
                  modules_per_string=1, strings=1,
-                 racking_model=None, array_losses_parameters=None,
+                 array_losses_parameters=None,
                  name=None):
-        self.surface_tilt = surface_tilt
-        self.surface_azimuth = surface_azimuth
+        self.mount = mount
 
         self.surface_type = surface_type
         if albedo is None:
@@ -1325,7 +1318,6 @@ class Array:
             self.module_parameters = module_parameters
 
         self.module_type = module_type
-        self.racking_model = racking_model
 
         self.strings = strings
         self.modules_per_string = modules_per_string
@@ -1344,10 +1336,11 @@ class Array:
         self.name = name
 
     def __repr__(self):
-        attrs = ['name', 'surface_tilt', 'surface_azimuth', 'module',
-                 'albedo', 'racking_model', 'module_type',
+        attrs = ['name', 'mount', 'module',
+                 'albedo', 'module_type',
                  'temperature_model_parameters',
                  'strings', 'modules_per_string']
+
         return 'Array:\n  ' + '\n  '.join(
             f'{attr}: {getattr(self, attr)}' for attr in attrs
         )
@@ -1355,7 +1348,7 @@ class Array:
     def _infer_temperature_model_params(self):
         # try to infer temperature model parameters from from racking_model
         # and module_type
-        param_set = f'{self.racking_model}_{self.module_type}'
+        param_set = f'{self.mount.racking_model}_{self.module_type}'
         if param_set in temperature.TEMPERATURE_MODEL_PARAMETERS['sapm']:
             return temperature._temperature_model_params('sapm', param_set)
         elif 'freestanding' in param_set:
@@ -1425,7 +1418,9 @@ class Array:
         aoi : Series
             Then angle of incidence.
         """
-        return irradiance.aoi(self.surface_tilt, self.surface_azimuth,
+        orientation = self.mount.get_orientation(solar_zenith, solar_azimuth)
+        return irradiance.aoi(orientation['surface_tilt'],
+                              orientation['surface_azimuth'],
                               solar_zenith, solar_azimuth)
 
     def get_irradiance(self, solar_zenith, solar_azimuth, dni, ghi, dhi,
@@ -1475,8 +1470,9 @@ class Array:
         if airmass is None:
             airmass = atmosphere.get_relative_airmass(solar_zenith)
 
-        return irradiance.get_total_irradiance(self.surface_tilt,
-                                               self.surface_azimuth,
+        orientation = self.mount.get_orientation(solar_zenith, solar_azimuth)
+        return irradiance.get_total_irradiance(orientation['surface_tilt'],
+                                               orientation['surface_azimuth'],
                                                solar_zenith, solar_azimuth,
                                                dni, ghi, dhi,
                                                dni_extra=dni_extra,
@@ -1597,13 +1593,11 @@ class Array:
             func = temperature.fuentes
             required = _build_tcell_args(['noct_installed'])
             optional = _build_kwargs([
-                'module_height', 'wind_height', 'emissivity', 'absorption',
+                'wind_height', 'emissivity', 'absorption',
                 'surface_tilt', 'module_width', 'module_length'],
                 self.temperature_model_parameters)
-            # default to using the Array attribute, but allow user to override
-            # with a custom surface_tilt value in temperature_model_parameters
-            if 'surface_tilt' not in optional:
-                optional['surface_tilt'] = self.surface_tilt
+            if self.mount.module_height is not None:
+                optional['module_height'] = self.mount.module_height
         elif model == 'noct_sam':
             func = functools.partial(temperature.noct_sam,
                                      effective_irradiance=effective_irradiance)
@@ -1685,6 +1679,145 @@ class Array:
             self.strings)
 
 
+@dataclass
+class AbstractMount(ABC):
+    """
+    A base class for Mount classes to extend. It is not intended to be
+    instantiated directly.
+    """
+
+    @abstractmethod
+    def get_orientation(self, solar_zenith, solar_azimuth):
+        """
+        Determine module orientation.
+
+        Parameters
+        ----------
+        solar_zenith : numeric
+            Solar apparent zenith angle [degrees]
+        solar_azimuth : numeric
+            Solar azimuth angle [degrees]
+
+        Returns
+        -------
+        orientation : dict-like
+            A dict-like object with keys `'surface_tilt', 'surface_azimuth'`
+            (typically a dict or pandas.DataFrame)
+        """
+
+
+@dataclass
+class FixedMount(AbstractMount):
+    """
+    Racking at fixed (static) orientation.
+
+    Parameters
+    ----------
+    surface_tilt : float, default 0
+        Surface tilt angle. The tilt angle is defined as angle from horizontal
+        (e.g. surface facing up = 0, surface facing horizon = 90) [degrees]
+
+    surface_azimuth : float, default 180
+        Azimuth angle of the module surface. North=0, East=90, South=180,
+        West=270. [degrees]
+
+    racking_model : str, optional
+        Valid strings are 'open_rack', 'close_mount', and 'insulated_back'.
+        Used to identify a parameter set for the SAPM cell temperature model.
+
+    module_height : float, optional
+       The height above ground of the center of the module [m]. Used for
+       the Fuentes cell temperature model.
+    """
+
+    surface_tilt: float = 0.0
+    surface_azimuth: float = 180.0
+    racking_model: Optional[str] = None
+    module_height: Optional[float] = None
+
+    def get_orientation(self, solar_zenith, solar_azimuth):
+        # note -- docstring is automatically inherited from AbstractMount
+        return {
+            'surface_tilt': self.surface_tilt,
+            'surface_azimuth': self.surface_azimuth,
+        }
+
+
+@dataclass
+class SingleAxisTrackerMount(AbstractMount):
+    """
+    Single-axis tracker racking for dynamic solar tracking.
+
+    Parameters
+    ----------
+    axis_tilt : float, default 0
+        The tilt of the axis of rotation (i.e, the y-axis defined by
+        axis_azimuth) with respect to horizontal. [degrees]
+
+    axis_azimuth : float, default 180
+        A value denoting the compass direction along which the axis of
+        rotation lies, measured east of north. [degrees]
+
+    max_angle : float, default 90
+        A value denoting the maximum rotation angle
+        of the one-axis tracker from its horizontal position (horizontal
+        if axis_tilt = 0). A max_angle of 90 degrees allows the tracker
+        to rotate to a vertical position to point the panel towards a
+        horizon. max_angle of 180 degrees allows for full rotation. [degrees]
+
+    backtrack : bool, default True
+        Controls whether the tracker has the capability to "backtrack"
+        to avoid row-to-row shading. False denotes no backtrack
+        capability. True denotes backtrack capability.
+
+    gcr : float, default 2.0/7.0
+        A value denoting the ground coverage ratio of a tracker system
+        which utilizes backtracking; i.e. the ratio between the PV array
+        surface area to total ground area. A tracker system with modules
+        2 meters wide, centered on the tracking axis, with 6 meters
+        between the tracking axes has a gcr of 2/6=0.333. If gcr is not
+        provided, a gcr of 2/7 is default. gcr must be <=1. [unitless]
+
+    cross_axis_tilt : float, default 0.0
+        The angle, relative to horizontal, of the line formed by the
+        intersection between the slope containing the tracker axes and a plane
+        perpendicular to the tracker axes. Cross-axis tilt should be specified
+        using a right-handed convention. For example, trackers with axis
+        azimuth of 180 degrees (heading south) will have a negative cross-axis
+        tilt if the tracker axes plane slopes down to the east and positive
+        cross-axis tilt if the tracker axes plane slopes up to the east. Use
+        :func:`~pvlib.tracking.calc_cross_axis_tilt` to calculate
+        `cross_axis_tilt`. [degrees]
+
+    racking_model : str, optional
+        Valid strings are 'open_rack', 'close_mount', and 'insulated_back'.
+        Used to identify a parameter set for the SAPM cell temperature model.
+
+    module_height : float, optional
+       The height above ground of the center of the module [m]. Used for
+       the Fuentes cell temperature model.
+    """
+    axis_tilt: float = 0.0
+    axis_azimuth: float = 0.0
+    max_angle: float = 90.0
+    backtrack: bool = True
+    gcr: float = 2.0/7.0
+    cross_axis_tilt: float = 0.0
+    racking_model: Optional[str] = None
+    module_height: Optional[float] = None
+
+    def get_orientation(self, solar_zenith, solar_azimuth):
+        # note -- docstring is automatically inherited from AbstractMount
+        from pvlib import tracking  # avoid circular import issue
+        tracking_data = tracking.singleaxis(
+            solar_zenith, solar_azimuth,
+            self.axis_tilt, self.axis_azimuth,
+            self.max_angle, self.backtrack,
+            self.gcr, self.cross_axis_tilt
+        )
+        return tracking_data
+
+
 def calcparams_desoto(effective_irradiance, temp_cell,
                       alpha_sc, a_ref, I_L_ref, I_o_ref, R_sh_ref, R_s,
                       EgRef=1.121, dEgdT=-0.0002677,
diff --git a/pvlib/solarposition.py b/pvlib/solarposition.py
index 9f7a77a..4047187 100644
--- a/pvlib/solarposition.py
+++ b/pvlib/solarposition.py
@@ -445,7 +445,7 @@ def sun_rise_set_transit_spa(times, latitude, longitude, how='numpy',
 
     # must convert to midnight UTC on day of interest
     utcday = pd.DatetimeIndex(times.date).tz_localize('UTC')
-    unixtime = np.array(utcday.astype(np.int64)/10**9)
+    unixtime = np.array(utcday.view(np.int64)/10**9)
 
     spa = _spa_python_import(how)
 
@@ -1001,7 +1001,7 @@ def nrel_earthsun_distance(time, how='numpy', delta_t=67.0, numthreads=4):
         except (TypeError, ValueError):
             time = pd.DatetimeIndex([time, ])
 
-    unixtime = np.array(time.astype(np.int64)/10**9)
+    unixtime = np.array(time.view(np.int64)/10**9)
 
     spa = _spa_python_import(how)
 
@@ -1381,8 +1381,8 @@ def hour_angle(times, longitude, equation_of_time):
     naive_times = times.tz_localize(None)  # naive but still localized
     # hours - timezone = (times - normalized_times) - (naive_times - times)
     hrs_minus_tzs = 1 / NS_PER_HR * (
-        2 * times.astype(np.int64) - times.normalize().astype(np.int64) -
-        naive_times.astype(np.int64))
+        2 * times.view(np.int64) - times.normalize().view(np.int64) -
+        naive_times.view(np.int64))
     # ensure array return instead of a version-dependent pandas <T>Index
     return np.asarray(
         15. * (hrs_minus_tzs - 12.) + longitude + equation_of_time / 4.)
@@ -1392,7 +1392,7 @@ def _hour_angle_to_hours(times, hourangle, longitude, equation_of_time):
     """converts hour angles in degrees to hours as a numpy array"""
     naive_times = times.tz_localize(None)  # naive but still localized
     tzs = 1 / NS_PER_HR * (
-        naive_times.astype(np.int64) - times.astype(np.int64))
+        naive_times.view(np.int64) - times.view(np.int64))
     hours = (hourangle - longitude - equation_of_time / 4.) / 15. + 12. + tzs
     return np.asarray(hours)
 
@@ -1406,7 +1406,7 @@ def _local_times_from_hours_since_midnight(times, hours):
     # normalize local, naive times to previous midnight and add the hours until
     # sunrise, sunset, and transit
     return pd.DatetimeIndex(
-        (naive_times.normalize().astype(np.int64) +
+        (naive_times.normalize().view(np.int64) +
          (hours * NS_PER_HR).astype(np.int64)).astype('datetime64[ns]'),
         tz=tz_info)
 
@@ -1415,7 +1415,7 @@ def _times_to_hours_after_local_midnight(times):
     """convert local pandas datetime indices to array of hours as floats"""
     times = times.tz_localize(None)
     hrs = 1 / NS_PER_HR * (
-        times.astype(np.int64) - times.normalize().astype(np.int64))
+        times.view(np.int64) - times.normalize().view(np.int64))
     return np.array(hrs)
 
 
diff --git a/pvlib/tracking.py b/pvlib/tracking.py
index 9aa4919..032b35d 100644
--- a/pvlib/tracking.py
+++ b/pvlib/tracking.py
@@ -2,10 +2,14 @@ import numpy as np
 import pandas as pd
 
 from pvlib.tools import cosd, sind, tand
-from pvlib.pvsystem import PVSystem, _unwrap_single_value
+from pvlib.pvsystem import (
+    PVSystem, Array, SingleAxisTrackerMount, _unwrap_single_value
+)
 from pvlib import irradiance, atmosphere
+from pvlib._deprecation import deprecated
 
 
+@deprecated('0.9.0', alternative='PVSystem with SingleAxisTrackerMount')
 class SingleAxisTracker(PVSystem):
     """
     A class for single-axis trackers that inherits the PV modeling methods from
@@ -76,20 +80,31 @@ class SingleAxisTracker(PVSystem):
     def __init__(self, axis_tilt=0, axis_azimuth=0, max_angle=90,
                  backtrack=True, gcr=2.0/7.0, cross_axis_tilt=0.0, **kwargs):
 
-        arrays = kwargs.get('arrays', [])
-        if len(arrays) > 1:
-            raise ValueError("SingleAxisTracker does not support "
-                             "multiple arrays.")
-        elif len(arrays) == 1:
-            surface_tilt = arrays[0].surface_tilt
-            surface_azimuth = arrays[0].surface_azimuth
-            if surface_tilt is not None or surface_azimuth is not None:
-                raise ValueError(
-                    "Array must not have surface_tilt or "
-                    "surface_azimuth assigned. You must pass an "
-                    "Array with these fields set to None."
-                )
-
+        mount_kwargs = {
+            k: kwargs.pop(k) for k in ['racking_model', 'module_height']
+            if k in kwargs
+        }
+        mount = SingleAxisTrackerMount(axis_tilt, axis_azimuth, max_angle,
+                                       backtrack, gcr, cross_axis_tilt,
+                                       **mount_kwargs)
+
+        array_defaults = {
+            'albedo': None, 'surface_type': None, 'module': None,
+            'module_type': None, 'module_parameters': None,
+            'temperature_model_parameters': None,
+            'modules_per_string': 1,
+        }
+        array_kwargs = {
+            key: kwargs.get(key, array_defaults[key]) for key in array_defaults
+        }
+        # strings/strings_per_inverter is a special case
+        array_kwargs['strings'] = kwargs.get('strings_per_inverter', 1)
+
+        array = Array(mount=mount, **array_kwargs)
+        pass_through_kwargs = {  # other args to pass to PVSystem()
+            k: v for k, v in kwargs.items() if k not in array_defaults
+        }
+        # leave these in case someone is using them
         self.axis_tilt = axis_tilt
         self.axis_azimuth = axis_azimuth
         self.max_angle = max_angle
@@ -97,10 +112,10 @@ class SingleAxisTracker(PVSystem):
         self.gcr = gcr
         self.cross_axis_tilt = cross_axis_tilt
 
-        kwargs['surface_tilt'] = None
-        kwargs['surface_azimuth'] = None
+        pass_through_kwargs['surface_tilt'] = None
+        pass_through_kwargs['surface_azimuth'] = None
 
-        super().__init__(**kwargs)
+        super().__init__(arrays=[array], **pass_through_kwargs)
 
     def __repr__(self):
         attrs = ['axis_tilt', 'axis_azimuth', 'max_angle', 'backtrack', 'gcr',


Test patch:
diff --git a/pvlib/tests/conftest.py b/pvlib/tests/conftest.py
index 95d0f72..a3cba1e 100644
--- a/pvlib/tests/conftest.py
+++ b/pvlib/tests/conftest.py
@@ -3,6 +3,7 @@ import platform
 import warnings
 
 import pandas as pd
+import os
 from pkg_resources import parse_version
 import pytest
 from functools import wraps
@@ -82,6 +83,18 @@ skip_windows = pytest.mark.skipif(platform_is_windows,
                                   reason='does not run on windows')
 
 
+try:
+    # Attempt to load BSRN credentials used for testing pvlib.iotools.get_bsrn
+    bsrn_username = os.environ["BSRN_FTP_USERNAME"]
+    bsrn_password = os.environ["BSRN_FTP_PASSWORD"]
+    has_bsrn_credentials = True
+except KeyError:
+    has_bsrn_credentials = False
+
+requires_bsrn_credentials = pytest.mark.skipif(
+    not has_bsrn_credentials, reason='requires bsrn credentials')
+
+
 try:
     import statsmodels  # noqa: F401
     has_statsmodels = True
diff --git a/pvlib/tests/iotools/test_bsrn.py b/pvlib/tests/iotools/test_bsrn.py
index 18d4be6..412cbd5 100644
--- a/pvlib/tests/iotools/test_bsrn.py
+++ b/pvlib/tests/iotools/test_bsrn.py
@@ -2,25 +2,123 @@
 tests for :mod:`pvlib.iotools.bsrn`
 """
 
-
 import pandas as pd
 import pytest
+import os
+from pvlib.iotools import read_bsrn, get_bsrn
+from ..conftest import (DATA_DIR, RERUNS, RERUNS_DELAY, assert_index_equal,
+                        requires_bsrn_credentials)
+
+
+@pytest.fixture(scope="module")
+def bsrn_credentials():
+    """Supplies the BSRN FTP credentials for testing purposes.
 
-from pvlib.iotools import bsrn
-from ..conftest import DATA_DIR, assert_index_equal
+    Users should obtain their own credentials as described in the `read_bsrn`
+    documentation."""
+    bsrn_username = os.environ["BSRN_FTP_USERNAME"]
+    bsrn_password = os.environ["BSRN_FTP_PASSWORD"]
+    return bsrn_username, bsrn_password
 
 
-@pytest.mark.parametrize('testfile,expected_index', [
-    ('bsrn-pay0616.dat.gz',
-     pd.date_range(start='20160601', periods=43200, freq='1min', tz='UTC')),
-    ('bsrn-lr0100-pay0616.dat',
-     pd.date_range(start='20160601', periods=43200, freq='1min', tz='UTC')),
+@pytest.fixture
+def expected_index():
+    return pd.date_range(start='20160601', periods=43200, freq='1min',
+                         tz='UTC')
+
+
+@pytest.mark.parametrize('testfile', [
+    ('bsrn-pay0616.dat.gz'),
+    ('bsrn-lr0100-pay0616.dat'),
 ])
 def test_read_bsrn(testfile, expected_index):
-    data = bsrn.read_bsrn(DATA_DIR / testfile)
+    data, metadata = read_bsrn(DATA_DIR / testfile)
+    assert_index_equal(expected_index, data.index)
+    assert 'ghi' in data.columns
+    assert 'dni_std' in data.columns
+    assert 'dhi_min' in data.columns
+    assert 'lwd_max' in data.columns
+    assert 'relative_humidity' in data.columns
+
+
+def test_read_bsrn_logical_records(expected_index):
+    # Test if logical records 0300 and 0500 are correct parsed
+    # and that 0100 is not passed when not specified
+    data, metadata = read_bsrn(DATA_DIR / 'bsrn-pay0616.dat.gz',
+                               logical_records=['0300', '0500'])
+    assert_index_equal(expected_index, data.index)
+    assert 'lwu' in data.columns
+    assert 'uva_global' in data.columns
+    assert 'uvb_reflected_std' in data.columns
+    assert 'ghi' not in data.columns
+
+
+def test_read_bsrn_bad_logical_record():
+    # Test if ValueError is raised if an unsupported logical record is passed
+    with pytest.raises(ValueError, match='not in'):
+        read_bsrn(DATA_DIR / 'bsrn-lr0100-pay0616.dat',
+                  logical_records=['dummy'])
+
+
+def test_read_bsrn_logical_records_not_found():
+    # Test if an empty dataframe is returned if specified LRs are not present
+    data, metadata = read_bsrn(DATA_DIR / 'bsrn-lr0100-pay0616.dat',
+                               logical_records=['0300', '0500'])
+    assert data.empty  # assert that the dataframe is empty
+    assert 'uva_global' in data.columns
+    assert 'uvb_reflected_std' in data.columns
+    assert 'uva_global_max' in data.columns
+    assert 'dni' not in data.columns
+    assert 'day' not in data.columns
+
+
+@requires_bsrn_credentials
+@pytest.mark.remote_data
+@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY)
+def test_get_bsrn(expected_index, bsrn_credentials):
+    # Retrieve irradiance data from the BSRN FTP server
+    # the TAM station is chosen due to its small file sizes
+    username, password = bsrn_credentials
+    data, metadata = get_bsrn(
+        start=pd.Timestamp(2016, 6, 1),
+        end=pd.Timestamp(2016, 6, 29),
+        station='tam',
+        username=username,
+        password=password,
+        local_path='')
     assert_index_equal(expected_index, data.index)
     assert 'ghi' in data.columns
     assert 'dni_std' in data.columns
     assert 'dhi_min' in data.columns
     assert 'lwd_max' in data.columns
     assert 'relative_humidity' in data.columns
+
+
+@requires_bsrn_credentials
+@pytest.mark.remote_data
+@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY)
+def test_get_bsrn_bad_station(bsrn_credentials):
+    # Test if KeyError is raised if a bad station name is passed
+    username, password = bsrn_credentials
+    with pytest.raises(KeyError, match='sub-directory does not exist'):
+        get_bsrn(
+            start=pd.Timestamp(2016, 6, 1),
+            end=pd.Timestamp(2016, 6, 29),
+            station='not_a_station_name',
+            username=username,
+            password=password)
+
+
+@requires_bsrn_credentials
+@pytest.mark.remote_data
+@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY)
+def test_get_bsrn_no_files(bsrn_credentials):
+    username, password = bsrn_credentials
+    # Test if Warning is given if no files are found for the entire time frame
+    with pytest.warns(UserWarning, match='No files'):
+        get_bsrn(
+            start=pd.Timestamp(1990, 6, 1),
+            end=pd.Timestamp(1990, 6, 29),
+            station='tam',
+            username=username,
+            password=password)
diff --git a/pvlib/tests/test_modelchain.py b/pvlib/tests/test_modelchain.py
index 4d2edbc..e52bda7 100644
--- a/pvlib/tests/test_modelchain.py
+++ b/pvlib/tests/test_modelchain.py
@@ -55,13 +55,13 @@ def cec_dc_snl_ac_arrays(cec_module_cs5p_220m, cec_inverter_parameters,
     module_parameters['dEgdT'] = -0.0002677
     temp_model_params = sapm_temperature_cs5p_220m.copy()
     array_one = pvsystem.Array(
-        surface_tilt=32.2, surface_azimuth=180,
+        mount=pvsystem.FixedMount(surface_tilt=32.2, surface_azimuth=180),
         module=module_parameters['Name'],
         module_parameters=module_parameters.copy(),
         temperature_model_parameters=temp_model_params.copy()
     )
     array_two = pvsystem.Array(
-        surface_tilt=42.2, surface_azimuth=220,
+        mount=pvsystem.FixedMount(surface_tilt=42.2, surface_azimuth=220),
         module=module_parameters['Name'],
         module_parameters=module_parameters.copy(),
         temperature_model_parameters=temp_model_params.copy()
@@ -109,13 +109,13 @@ def pvsyst_dc_snl_ac_arrays(pvsyst_module_params, cec_inverter_parameters,
     module_parameters['b'] = 0.05
     temp_model_params = sapm_temperature_cs5p_220m.copy()
     array_one = pvsystem.Array(
-        surface_tilt=32.2, surface_azimuth=180,
+        mount=pvsystem.FixedMount(surface_tilt=32.2, surface_azimuth=180),
         module=module,
         module_parameters=module_parameters.copy(),
         temperature_model_parameters=temp_model_params.copy()
     )
     array_two = pvsystem.Array(
-        surface_tilt=42.2, surface_azimuth=220,
+        mount=pvsystem.FixedMount(surface_tilt=42.2, surface_azimuth=220),
         module=module,
         module_parameters=module_parameters.copy(),
         temperature_model_parameters=temp_model_params.copy()
@@ -172,12 +172,12 @@ def pvwatts_dc_pvwatts_ac_system_arrays(sapm_temperature_cs5p_220m):
     temp_model_params = sapm_temperature_cs5p_220m.copy()
     inverter_parameters = {'pdc0': 220, 'eta_inv_nom': 0.95}
     array_one = pvsystem.Array(
-        surface_tilt=32.2, surface_azimuth=180,
+        mount=pvsystem.FixedMount(surface_tilt=32.2, surface_azimuth=180),
         module_parameters=module_parameters.copy(),
         temperature_model_parameters=temp_model_params.copy()
     )
     array_two = pvsystem.Array(
-        surface_tilt=42.2, surface_azimuth=220,
+        mount=pvsystem.FixedMount(surface_tilt=42.2, surface_azimuth=220),
         module_parameters=module_parameters.copy(),
         temperature_model_parameters=temp_model_params.copy()
     )
@@ -288,13 +288,15 @@ def sapm_dc_snl_ac_system_Array(sapm_module_params, cec_inverter_parameters,
     module = 'Canadian_Solar_CS5P_220M___2009_'
     module_parameters = sapm_module_params.copy()
     temp_model_params = sapm_temperature_cs5p_220m.copy()
-    array_one = pvsystem.Array(surface_tilt=32, surface_azimuth=180,
+    array_one = pvsystem.Array(mount=pvsystem.FixedMount(surface_tilt=32,
+                                                         surface_azimuth=180),
                                albedo=0.2, module=module,
                                module_parameters=module_parameters,
                                temperature_model_parameters=temp_model_params,
                                modules_per_string=1,
                                strings=1)
-    array_two = pvsystem.Array(surface_tilt=15, surface_azimuth=180,
+    array_two = pvsystem.Array(mount=pvsystem.FixedMount(surface_tilt=15,
+                                                         surface_azimuth=180),
                                albedo=0.2, module=module,
                                module_parameters=module_parameters,
                                temperature_model_parameters=temp_model_params,
@@ -312,13 +314,15 @@ def sapm_dc_snl_ac_system_same_arrays(sapm_module_params,
     module = 'Canadian_Solar_CS5P_220M___2009_'
     module_parameters = sapm_module_params.copy()
     temp_model_params = sapm_temperature_cs5p_220m.copy()
-    array_one = pvsystem.Array(surface_tilt=32.2, surface_azimuth=180,
+    array_one = pvsystem.Array(mount=pvsystem.FixedMount(surface_tilt=32.2,
+                                                         surface_azimuth=180),
                                module=module,
                                module_parameters=module_parameters,
                                temperature_model_parameters=temp_model_params,
                                modules_per_string=1,
                                strings=1)
-    array_two = pvsystem.Array(surface_tilt=32.2, surface_azimuth=180,
+    array_two = pvsystem.Array(mount=pvsystem.FixedMount(surface_tilt=32.2,
+                                                         surface_azimuth=180),
                                module=module,
                                module_parameters=module_parameters,
                                temperature_model_parameters=temp_model_params,
@@ -365,12 +369,12 @@ def multi_array_sapm_dc_snl_ac_system(
     temp_model_parameters = sapm_temperature_cs5p_220m.copy()
     inverter_parameters = cec_inverter_parameters
     array_one = pvsystem.Array(
-        surface_tilt=32.2, surface_azimuth=180,
+        mount=pvsystem.FixedMount(surface_tilt=32.2, surface_azimuth=180),
         module_parameters=module_parameters,
         temperature_model_parameters=temp_model_parameters
     )
     array_two = pvsystem.Array(
-        surface_tilt=32.2, surface_azimuth=220,
+        mount=pvsystem.FixedMount(surface_tilt=32.2, surface_azimuth=220),
         module_parameters=module_parameters,
         temperature_model_parameters=temp_model_parameters
     )
@@ -696,7 +700,7 @@ def test_run_model_with_weather_fuentes_temp(sapm_dc_snl_ac_system, location,
     weather['wind_speed'] = 5
     weather['temp_air'] = 10
     sapm_dc_snl_ac_system.arrays[0].temperature_model_parameters = {
-        'noct_installed': 45
+        'noct_installed': 45, 'surface_tilt': 30,
     }
     mc = ModelChain(sapm_dc_snl_ac_system, location)
     mc.temperature_model = 'fuentes'
@@ -730,12 +734,13 @@ def test_run_model_with_weather_noct_sam_temp(sapm_dc_snl_ac_system, location,
 
 
 def test_run_model_tracker(sapm_dc_snl_ac_system, location, weather, mocker):
-    system = SingleAxisTracker(
-        module_parameters=sapm_dc_snl_ac_system.arrays[0].module_parameters,
-        temperature_model_parameters=(
-            sapm_dc_snl_ac_system.arrays[0].temperature_model_parameters
-        ),
-        inverter_parameters=sapm_dc_snl_ac_system.inverter_parameters)
+    with pytest.warns(pvlibDeprecationWarning):
+        system = SingleAxisTracker(
+            module_parameters=sapm_dc_snl_ac_system.arrays[0].module_parameters,  # noqa: E501
+            temperature_model_parameters=(
+                sapm_dc_snl_ac_system.arrays[0].temperature_model_parameters
+            ),
+            inverter_parameters=sapm_dc_snl_ac_system.inverter_parameters)
     mocker.spy(system, 'singleaxis')
     mc = ModelChain(system, location)
     mc.run_model(weather)
@@ -751,12 +756,13 @@ def test_run_model_tracker(sapm_dc_snl_ac_system, location, weather, mocker):
 
 def test_run_model_tracker_list(
         sapm_dc_snl_ac_system, location, weather, mocker):
-    system = SingleAxisTracker(
-        module_parameters=sapm_dc_snl_ac_system.arrays[0].module_parameters,
-        temperature_model_parameters=(
-            sapm_dc_snl_ac_system.arrays[0].temperature_model_parameters
-        ),
-        inverter_parameters=sapm_dc_snl_ac_system.inverter_parameters)
+    with pytest.warns(pvlibDeprecationWarning):
+        system = SingleAxisTracker(
+            module_parameters=sapm_dc_snl_ac_system.arrays[0].module_parameters,  # noqa: E501
+            temperature_model_parameters=(
+                sapm_dc_snl_ac_system.arrays[0].temperature_model_parameters
+            ),
+            inverter_parameters=sapm_dc_snl_ac_system.inverter_parameters)
     mocker.spy(system, 'singleaxis')
     mc = ModelChain(system, location)
     mc.run_model([weather])
@@ -1023,12 +1029,13 @@ def test_run_model_from_poa_arrays_solar_position_weather(
 
 def test_run_model_from_poa_tracking(sapm_dc_snl_ac_system, location,
                                      total_irrad):
-    system = SingleAxisTracker(
-        module_parameters=sapm_dc_snl_ac_system.arrays[0].module_parameters,
-        temperature_model_parameters=(
-            sapm_dc_snl_ac_system.arrays[0].temperature_model_parameters
-        ),
-        inverter_parameters=sapm_dc_snl_ac_system.inverter_parameters)
+    with pytest.warns(pvlibDeprecationWarning):
+        system = SingleAxisTracker(
+            module_parameters=sapm_dc_snl_ac_system.arrays[0].module_parameters,  # noqa: E501
+            temperature_model_parameters=(
+                sapm_dc_snl_ac_system.arrays[0].temperature_model_parameters
+            ),
+            inverter_parameters=sapm_dc_snl_ac_system.inverter_parameters)
     mc = ModelChain(system, location, aoi_model='no_loss',
                     spectral_model='no_loss')
     ac = mc.run_model_from_poa(total_irrad).results.ac
@@ -1439,6 +1446,7 @@ def test_infer_ac_model_invalid_params(location):
     module_parameters = {'pdc0': 1, 'gamma_pdc': 1}
     system = pvsystem.PVSystem(
         arrays=[pvsystem.Array(
+            mount=pvsystem.FixedMount(0, 180),
             module_parameters=module_parameters
         )],
         inverter_parameters={'foo': 1, 'bar': 2}
@@ -1953,10 +1961,13 @@ def test_inconsistent_array_params(location,
     different_module_system = pvsystem.PVSystem(
         arrays=[
             pvsystem.Array(
+                mount=pvsystem.FixedMount(0, 180),
                 module_parameters=sapm_module_params),
             pvsystem.Array(
+                mount=pvsystem.FixedMount(0, 180),
                 module_parameters=cec_module_params),
             pvsystem.Array(
+                mount=pvsystem.FixedMount(0, 180),
                 module_parameters=cec_module_params)]
     )
     with pytest.raises(ValueError, match=module_error):
@@ -1964,16 +1975,19 @@ def test_inconsistent_array_params(location,
     different_temp_system = pvsystem.PVSystem(
         arrays=[
             pvsystem.Array(
+                mount=pvsystem.FixedMount(0, 180),
                 module_parameters=cec_module_params,
                 temperature_model_parameters={'a': 1,
                                               'b': 1,
                                               'deltaT': 1}),
             pvsystem.Array(
+                mount=pvsystem.FixedMount(0, 180),
                 module_parameters=cec_module_params,
                 temperature_model_parameters={'a': 2,
                                               'b': 2,
                                               'deltaT': 2}),
             pvsystem.Array(
+                mount=pvsystem.FixedMount(0, 180),
                 module_parameters=cec_module_params,
                 temperature_model_parameters={'b': 3, 'deltaT': 3})]
     )
diff --git a/pvlib/tests/test_pvsystem.py b/pvlib/tests/test_pvsystem.py
index 4533e58..505016a 100644
--- a/pvlib/tests/test_pvsystem.py
+++ b/pvlib/tests/test_pvsystem.py
@@ -15,6 +15,7 @@ from pvlib import atmosphere
 from pvlib import iam as _iam
 from pvlib import irradiance
 from pvlib.location import Location
+from pvlib.pvsystem import FixedMount
 from pvlib import temperature
 from pvlib._deprecation import pvlibDeprecationWarning
 
@@ -36,8 +37,10 @@ def test_PVSystem_get_iam(mocker, iam_model, model_params):
 def test_PVSystem_multi_array_get_iam():
     model_params = {'b': 0.05}
     system = pvsystem.PVSystem(
-        arrays=[pvsystem.Array(module_parameters=model_params),
-                pvsystem.Array(module_parameters=model_params)]
+        arrays=[pvsystem.Array(mount=pvsystem.FixedMount(0, 180),
+                               module_parameters=model_params),
+                pvsystem.Array(mount=pvsystem.FixedMount(0, 180),
+                               module_parameters=model_params)]
     )
     iam = system.get_iam((1, 5), iam_model='ashrae')
     assert len(iam) == 2
@@ -226,8 +229,10 @@ def test_PVSystem_sapm(sapm_module_params, mocker):
 
 def test_PVSystem_multi_array_sapm(sapm_module_params):
     system = pvsystem.PVSystem(
-        arrays=[pvsystem.Array(module_parameters=sapm_module_params),
-                pvsystem.Array(module_parameters=sapm_module_params)]
+        arrays=[pvsystem.Array(pvsystem.FixedMount(0, 180),
+                               module_parameters=sapm_module_params),
+                pvsystem.Array(pvsystem.FixedMount(0, 180),
+                               module_parameters=sapm_module_params)]
     )
     effective_irradiance = (100, 500)
     temp_cell = (15, 25)
@@ -274,8 +279,10 @@ def test_PVSystem_sapm_spectral_loss(sapm_module_params, mocker):
 
 def test_PVSystem_multi_array_sapm_spectral_loss(sapm_module_params):
     system = pvsystem.PVSystem(
-        arrays=[pvsystem.Array(module_parameters=sapm_module_params),
-                pvsystem.Array(module_parameters=sapm_module_params)]
+        arrays=[pvsystem.Array(pvsystem.FixedMount(0, 180),
+                               module_parameters=sapm_module_params),
+                pvsystem.Array(pvsystem.FixedMount(0, 180),
+                               module_parameters=sapm_module_params)]
     )
     loss_one, loss_two = system.sapm_spectral_loss(2)
     assert loss_one == loss_two
@@ -308,10 +315,12 @@ def test_PVSystem_multi_array_first_solar_spectral_loss():
     system = pvsystem.PVSystem(
         arrays=[
             pvsystem.Array(
+                mount=pvsystem.FixedMount(0, 180),
                 module_parameters={'Technology': 'mc-Si'},
                 module_type='multisi'
             ),
             pvsystem.Array(
+                mount=pvsystem.FixedMount(0, 180),
                 module_parameters={'Technology': 'mc-Si'},
                 module_type='multisi'
             )
@@ -363,8 +372,10 @@ def test_PVSystem_sapm_effective_irradiance(sapm_module_params, mocker):
 
 def test_PVSystem_multi_array_sapm_effective_irradiance(sapm_module_params):
     system = pvsystem.PVSystem(
-        arrays=[pvsystem.Array(module_parameters=sapm_module_params),
-                pvsystem.Array(module_parameters=sapm_module_params)]
+        arrays=[pvsystem.Array(pvsystem.FixedMount(0, 180),
+                               module_parameters=sapm_module_params),
+                pvsystem.Array(pvsystem.FixedMount(0, 180),
+                               module_parameters=sapm_module_params)]
     )
     poa_direct = (500, 900)
     poa_diffuse = (50, 100)
@@ -397,10 +408,12 @@ def two_array_system(pvsyst_module_params, cec_module_params):
     return pvsystem.PVSystem(
         arrays=[
             pvsystem.Array(
+                mount=pvsystem.FixedMount(0, 180),
                 temperature_model_parameters=temperature_model,
                 module_parameters=module_params
             ),
             pvsystem.Array(
+                mount=pvsystem.FixedMount(0, 180),
                 temperature_model_parameters=temperature_model,
                 module_parameters=module_params
             )
@@ -457,8 +470,10 @@ def test_PVSystem_multi_array_sapm_celltemp_different_arrays():
     temp_model_two = temperature.TEMPERATURE_MODEL_PARAMETERS['sapm'][
         'close_mount_glass_glass']
     system = pvsystem.PVSystem(
-        arrays=[pvsystem.Array(temperature_model_parameters=temp_model_one),
-                pvsystem.Array(temperature_model_parameters=temp_model_two)]
+        arrays=[pvsystem.Array(pvsystem.FixedMount(0, 180),
+                               temperature_model_parameters=temp_model_one),
+                pvsystem.Array(pvsystem.FixedMount(0, 180),
+                               temperature_model_parameters=temp_model_two)]
     )
     temp_one, temp_two = system.get_cell_temperature(
         (1000, 1000), 25, 1, model='sapm'
@@ -555,6 +570,7 @@ def test_PVSystem_multi_array_celltemp_functions(model, two_array_system):
     irrad_two = pd.Series(500, index=times)
     temp_air = pd.Series(25, index=times)
     wind_speed = pd.Series(1, index=times)
+
     temp_one, temp_two = two_array_system.get_cell_temperature(
         (irrad_one, irrad_two), temp_air, wind_speed, model=model)
     assert (temp_one != temp_two).all()
@@ -667,7 +683,7 @@ def test_PVSystem_multi_array_celltemp_poa_length_mismatch(
 
 def test_PVSystem_fuentes_celltemp(mocker):
     noct_installed = 45
-    temp_model_params = {'noct_installed': noct_installed}
+    temp_model_params = {'noct_installed': noct_installed, 'surface_tilt': 0}
     system = pvsystem.PVSystem(temperature_model_parameters=temp_model_params)
     spy = mocker.spy(temperature, 'fuentes')
     index = pd.date_range('2019-01-01 11:00', freq='h', periods=3)
@@ -683,47 +699,38 @@ def test_PVSystem_fuentes_celltemp(mocker):
                                        name='tmod'))
 
 
-def test_PVSystem_fuentes_celltemp_override(mocker):
-    # test that the surface_tilt value in the cell temp calculation can be
-    # overridden but defaults to the surface_tilt attribute of the PVSystem
+def test_PVSystem_fuentes_module_height(mocker):
+    # check that fuentes picks up Array.mount.module_height correctly
+    # (temperature.fuentes defaults to 5 for module_height)
+    array = pvsystem.Array(mount=FixedMount(module_height=3),
+                           temperature_model_parameters={'noct_installed': 45})
     spy = mocker.spy(temperature, 'fuentes')
-
-    noct_installed = 45
     index = pd.date_range('2019-01-01 11:00', freq='h', periods=3)
     temps = pd.Series(25, index)
     irrads = pd.Series(1000, index)
     winds = pd.Series(1, index)
-
-    # uses default value
-    temp_model_params = {'noct_installed': noct_installed}
-    system = pvsystem.PVSystem(temperature_model_parameters=temp_model_params,
-                               surface_tilt=20)
-    system.get_cell_temperature(irrads, temps, winds, model='fuentes')
-    assert spy.call_args[1]['surface_tilt'] == 20
-
-    # can be overridden
-    temp_model_params = {'noct_installed': noct_installed, 'surface_tilt': 30}
-    system = pvsystem.PVSystem(temperature_model_parameters=temp_model_params,
-                               surface_tilt=20)
-    system.get_cell_temperature(irrads, temps, winds, model='fuentes')
-    assert spy.call_args[1]['surface_tilt'] == 30
+    _ = array.get_cell_temperature(irrads, temps, winds, model='fuentes')
+    assert spy.call_args[1]['module_height'] == 3
 
 
 def test_Array__infer_temperature_model_params():
-    array = pvsystem.Array(module_parameters={},
-                           racking_model='open_rack',
+    array = pvsystem.Array(mount=FixedMount(0, 180,
+                                            racking_model='open_rack'),
+                           module_parameters={},
                            module_type='glass_polymer')
     expected = temperature.TEMPERATURE_MODEL_PARAMETERS[
         'sapm']['open_rack_glass_polymer']
     assert expected == array._infer_temperature_model_params()
-    array = pvsystem.Array(module_parameters={},
-                           racking_model='freestanding',
+    array = pvsystem.Array(mount=FixedMount(0, 180,
+                                            racking_model='freestanding'),
+                           module_parameters={},
                            module_type='glass_polymer')
     expected = temperature.TEMPERATURE_MODEL_PARAMETERS[
         'pvsyst']['freestanding']
     assert expected == array._infer_temperature_model_params()
-    array = pvsystem.Array(module_parameters={},
-                           racking_model='insulated',
+    array = pvsystem.Array(mount=FixedMount(0, 180,
+                                            racking_model='insulated'),
+                           module_parameters={},
                            module_type=None)
     expected = temperature.TEMPERATURE_MODEL_PARAMETERS[
         'pvsyst']['insulated']
@@ -731,7 +738,8 @@ def test_Array__infer_temperature_model_params():
 
 
 def test_Array__infer_cell_type():
-    array = pvsystem.Array(module_parameters={})
+    array = pvsystem.Array(mount=pvsystem.FixedMount(0, 180),
+                           module_parameters={})
     assert array._infer_cell_type() is None
 
 
@@ -1434,8 +1442,10 @@ def test_PVSystem_scale_voltage_current_power(mocker):
 def test_PVSystem_multi_scale_voltage_current_power(mocker):
     data = (1, 2)
     system = pvsystem.PVSystem(
-        arrays=[pvsystem.Array(modules_per_string=2, strings=3),
-                pvsystem.Array(modules_per_string=3, strings=5)]
+        arrays=[pvsystem.Array(pvsystem.FixedMount(0, 180),
+                               modules_per_string=2, strings=3),
+                pvsystem.Array(pvsystem.FixedMount(0, 180),
+                               modules_per_string=3, strings=5)]
     )
     m = mocker.patch(
         'pvlib.pvsystem.scale_voltage_current_power', autospec=True
@@ -1482,7 +1492,8 @@ def test_PVSystem_snlinverter(cec_inverter_parameters):
 def test_PVSystem_get_ac_sandia_multi(cec_inverter_parameters, mocker):
     inv_fun = mocker.spy(inverter, 'sandia_multi')
     system = pvsystem.PVSystem(
-        arrays=[pvsystem.Array(), pvsystem.Array()],
+        arrays=[pvsystem.Array(pvsystem.FixedMount(0, 180)),
+                pvsystem.Array(pvsystem.FixedMount(0, 180))],
         inverter=cec_inverter_parameters['Name'],
         inverter_parameters=cec_inverter_parameters,
     )
@@ -1529,7 +1540,8 @@ def test_PVSystem_get_ac_pvwatts_multi(
     systems = [pvwatts_system_defaults, pvwatts_system_kwargs]
     for base_sys, exp in zip(systems, expected):
         system = pvsystem.PVSystem(
-            arrays=[pvsystem.Array(), pvsystem.Array()],
+            arrays=[pvsystem.Array(pvsystem.FixedMount(0, 180)),
+                    pvsystem.Array(pvsystem.FixedMount(0, 180),)],
             inverter_parameters=base_sys.inverter_parameters,
         )
         pdcs = pd.Series([0., 25., 50.])
@@ -1571,7 +1583,7 @@ def test_PVSystem_get_ac_single_array_tuple_input(
         'sandia': pd.Series([-0.020000, 132.004308, 250.000000])
     }
     system = pvsystem.PVSystem(
-        arrays=[pvsystem.Array()],
+        arrays=[pvsystem.Array(pvsystem.FixedMount(0, 180))],
         inverter_parameters=inverter_parameters[model]
     )
     ac = system.get_ac(p_dc=(pdcs[model],), v_dc=(vdcs[model],), model=model)
@@ -1597,7 +1609,8 @@ def test_PVSystem_get_ac_adr(adr_inverter_parameters, mocker):
 
 def test_PVSystem_get_ac_adr_multi(adr_inverter_parameters):
     system = pvsystem.PVSystem(
-        arrays=[pvsystem.Array(), pvsystem.Array()],
+        arrays=[pvsystem.Array(pvsystem.FixedMount(0, 180)),
+                pvsystem.Array(pvsystem.FixedMount(0, 180))],
         inverter_parameters=adr_inverter_parameters,
     )
     pdcs = pd.Series([135, 1232, 1170, 420, 551])
@@ -1625,9 +1638,12 @@ def test_PVSystem_creation():
 
 
 def test_PVSystem_multiple_array_creation():
-    array_one = pvsystem.Array(surface_tilt=32)
-    array_two = pvsystem.Array(surface_tilt=15, module_parameters={'pdc0': 1})
+    array_one = pvsystem.Array(pvsystem.FixedMount(surface_tilt=32))
+    array_two = pvsystem.Array(pvsystem.FixedMount(surface_tilt=15),
+                               module_parameters={'pdc0': 1})
     pv_system = pvsystem.PVSystem(arrays=[array_one, array_two])
+    assert pv_system.arrays[0].module_parameters == {}
+    assert pv_system.arrays[1].module_parameters == {'pdc0': 1}
     assert pv_system.arrays == (array_one, array_two)
     with pytest.raises(TypeError):
         pvsystem.PVSystem(arrays=array_one)
@@ -1641,8 +1657,10 @@ def test_PVSystem_get_aoi():
 
 def test_PVSystem_multiple_array_get_aoi():
     system = pvsystem.PVSystem(
-        arrays=[pvsystem.Array(surface_tilt=15, surface_azimuth=135),
-                pvsystem.Array(surface_tilt=32, surface_azimuth=135)]
+        arrays=[pvsystem.Array(pvsystem.FixedMount(surface_tilt=15,
+                                                   surface_azimuth=135)),
+                pvsystem.Array(pvsystem.FixedMount(surface_tilt=32,
+                                                   surface_azimuth=135))]
     )
     aoi_one, aoi_two = system.get_aoi(30, 225)
     assert np.round(aoi_two, 4) == 42.7408
@@ -1703,8 +1721,10 @@ def test_PVSystem_get_irradiance_model(mocker):
 
 
 def test_PVSystem_multi_array_get_irradiance():
-    array_one = pvsystem.Array(surface_tilt=32, surface_azimuth=135)
-    array_two = pvsystem.Array(surface_tilt=5, surface_azimuth=150)
+    array_one = pvsystem.Array(pvsystem.FixedMount(surface_tilt=32,
+                                                   surface_azimuth=135))
+    array_two = pvsystem.Array(pvsystem.FixedMount(surface_tilt=5,
+                                                   surface_azimuth=150))
     system = pvsystem.PVSystem(arrays=[array_one, array_two])
     location = Location(latitude=32, longitude=-111)
     times = pd.date_range(start='20160101 1200-0700',
@@ -1743,8 +1763,8 @@ def test_PVSystem_multi_array_get_irradiance_multi_irrad():
     for each array when different GHI/DHI/DNI input is given. For the later
     case we verify that the correct irradiance data is passed to each array.
     """
-    array_one = pvsystem.Array()
-    array_two = pvsystem.Array()
+    array_one = pvsystem.Array(pvsystem.FixedMount(0, 180))
+    array_two = pvsystem.Array(pvsystem.FixedMount(0, 180))
     system = pvsystem.PVSystem(arrays=[array_one, array_two])
     location = Location(latitude=32, longitude=-111)
     times = pd.date_range(start='20160101 1200-0700',
@@ -1804,40 +1824,6 @@ def test_PVSystem_multi_array_get_irradiance_multi_irrad():
     assert not array_irrad[0].equals(array_irrad[1])
 
 
-def test_PVSystem_change_surface_azimuth():
-    system = pvsystem.PVSystem(surface_azimuth=180)
-    with pytest.warns(pvlibDeprecationWarning):
-        assert system.surface_azimuth == 180
-    with pytest.warns(pvlibDeprecationWarning):
-        system.surface_azimuth = 90
-    with pytest.warns(pvlibDeprecationWarning):
-        assert system.surface_azimuth == 90
-
-
-def test_PVSystem_get_albedo():
-    system = pvsystem.PVSystem(
-        arrays=[pvsystem.Array(albedo=0.5)]
-    )
-    with pytest.warns(pvlibDeprecationWarning):
-        assert system.albedo == 0.5
-
-
-def test_PVSystem_modules_per_string():
-    system = pvsystem.PVSystem(
-        arrays=[pvsystem.Array(modules_per_string=5)]
-    )
-    with pytest.warns(pvlibDeprecationWarning):
-        assert system.modules_per_string == 5
-
-
-def test_PVSystem_strings_per_inverter():
-    system = pvsystem.PVSystem(
-        arrays=[pvsystem.Array(strings=5)]
-    )
-    with pytest.warns(pvlibDeprecationWarning):
-        assert system.strings_per_inverter == 5
-
-
 @fail_on_pvlib_version('0.10')
 @pytest.mark.parametrize('attr', ['module_parameters', 'module', 'module_type',
                                   'temperature_model_parameters', 'albedo',
@@ -1845,8 +1831,8 @@ def test_PVSystem_strings_per_inverter():
                                   'racking_model', 'modules_per_string',
                                   'strings_per_inverter'])
 def test_PVSystem_multi_array_attributes(attr):
-    array_one = pvsystem.Array()
-    array_two = pvsystem.Array()
+    array_one = pvsystem.Array(pvsystem.FixedMount())
+    array_two = pvsystem.Array(pvsystem.FixedMount())
     system = pvsystem.PVSystem(arrays=[array_one, array_two])
     with pytest.raises(AttributeError):
         getattr(system, attr)
@@ -1871,23 +1857,23 @@ def test_PVSystem___repr__():
   name: pv ftw
   Array:
     name: None
-    surface_tilt: 0
-    surface_azimuth: 180
+    mount: FixedMount(surface_tilt=0, surface_azimuth=180, racking_model=None, module_height=None)
     module: blah
     albedo: 0.25
-    racking_model: None
     module_type: None
     temperature_model_parameters: {'a': -3.56}
     strings: 1
     modules_per_string: 1
-  inverter: blarg"""
+  inverter: blarg"""  # noqa: E501
     assert system.__repr__() == expected
 
 
 def test_PVSystem_multi_array___repr__():
     system = pvsystem.PVSystem(
-        arrays=[pvsystem.Array(surface_tilt=30, surface_azimuth=100),
-                pvsystem.Array(surface_tilt=20, surface_azimuth=220,
+        arrays=[pvsystem.Array(pvsystem.FixedMount(surface_tilt=30,
+                                                   surface_azimuth=100)),
+                pvsystem.Array(pvsystem.FixedMount(surface_tilt=20,
+                                                   surface_azimuth=220),
                                name='foo')],
         inverter='blarg',
     )
@@ -1895,36 +1881,32 @@ def test_PVSystem_multi_array___repr__():
   name: None
   Array:
     name: None
-    surface_tilt: 30
-    surface_azimuth: 100
+    mount: FixedMount(surface_tilt=30, surface_azimuth=100, racking_model=None, module_height=None)
     module: None
     albedo: 0.25
-    racking_model: None
     module_type: None
     temperature_model_parameters: {}
     strings: 1
     modules_per_string: 1
   Array:
     name: foo
-    surface_tilt: 20
-    surface_azimuth: 220
+    mount: FixedMount(surface_tilt=20, surface_azimuth=220, racking_model=None, module_height=None)
     module: None
     albedo: 0.25
-    racking_model: None
     module_type: None
     temperature_model_parameters: {}
     strings: 1
     modules_per_string: 1
-  inverter: blarg"""
+  inverter: blarg"""  # noqa: E501
     assert expected == system.__repr__()
 
 
 def test_Array___repr__():
     array = pvsystem.Array(
-        surface_tilt=10, surface_azimuth=100,
+        mount=pvsystem.FixedMount(surface_tilt=10, surface_azimuth=100,
+                                  racking_model='close_mount'),
         albedo=0.15, module_type='glass_glass',
         temperature_model_parameters={'a': -3.56},
-        racking_model='close_mount',
         module_parameters={'foo': 'bar'},
         modules_per_string=100,
         strings=10, module='baz',
@@ -1932,15 +1914,13 @@ def test_Array___repr__():
     )
     expected = """Array:
   name: biz
-  surface_tilt: 10
-  surface_azimuth: 100
+  mount: FixedMount(surface_tilt=10, surface_azimuth=100, racking_model='close_mount', module_height=None)
   module: baz
   albedo: 0.15
-  racking_model: close_mount
   module_type: glass_glass
   temperature_model_parameters: {'a': -3.56}
   strings: 10
-  modules_per_string: 100"""
+  modules_per_string: 100"""  # noqa: E501
     assert array.__repr__() == expected
 
 
@@ -2035,12 +2015,14 @@ def test_PVSystem_multiple_array_pvwatts_dc():
         'pdc0': 100, 'gamma_pdc': -0.003, 'temp_ref': 20
     }
     array_one = pvsystem.Array(
+        pvsystem.FixedMount(0, 180),
         module_parameters=array_one_module_parameters
     )
     array_two_module_parameters = {
         'pdc0': 150, 'gamma_pdc': -0.002, 'temp_ref': 25
     }
     array_two = pvsystem.Array(
+        pvsystem.FixedMount(0, 180),
         module_parameters=array_two_module_parameters
     )
     system = pvsystem.PVSystem(arrays=[array_one, array_two])
@@ -2060,7 +2042,9 @@ def test_PVSystem_multiple_array_pvwatts_dc():
 
 def test_PVSystem_multiple_array_pvwatts_dc_value_error():
     system = pvsystem.PVSystem(
-        arrays=[pvsystem.Array(), pvsystem.Array(), pvsystem.Array()]
+        arrays=[pvsystem.Array(pvsystem.FixedMount(0, 180)),
+                pvsystem.Array(pvsystem.FixedMount(0, 180)),
+                pvsystem.Array(pvsystem.FixedMount(0, 180))]
     )
     error_message = 'Length mismatch for per-array parameter'
     with pytest.raises(ValueError, match=error_message):
@@ -2118,7 +2102,9 @@ def test_PVSystem_pvwatts_ac_kwargs(pvwatts_system_kwargs, mocker):
 
 def test_PVSystem_num_arrays():
     system_one = pvsystem.PVSystem()
-    system_two = pvsystem.PVSystem(arrays=[pvsystem.Array(), pvsystem.Array()])
+    system_two = pvsystem.PVSystem(arrays=[
+        pvsystem.Array(pvsystem.FixedMount(0, 180)),
+        pvsystem.Array(pvsystem.FixedMount(0, 180))])
     assert system_one.num_arrays == 1
     assert system_two.num_arrays == 2
 
@@ -2146,6 +2132,49 @@ def test_no_extra_kwargs():
         pvsystem.PVSystem(arbitrary_kwarg='value')
 
 
+def test_AbstractMount_constructor():
+    match = "Can't instantiate abstract class AbstractMount"
+    with pytest.raises(TypeError, match=match):
+        _ = pvsystem.AbstractMount()
+
+
+@pytest.fixture
+def fixed_mount():
+    return pvsystem.FixedMount(20, 180)
+
+
+@pytest.fixture
+def single_axis_tracker_mount():
+    return pvsystem.SingleAxisTrackerMount(axis_tilt=10, axis_azimuth=170,
+                                           max_angle=45, backtrack=False,
+                                           gcr=0.4, cross_axis_tilt=-5)
+
+
+def test_FixedMount_constructor(fixed_mount):
+    assert fixed_mount.surface_tilt == 20
+    assert fixed_mount.surface_azimuth == 180
+
+
+def test_FixedMount_get_orientation(fixed_mount):
+    expected = {'surface_tilt': 20, 'surface_azimuth': 180}
+    assert fixed_mount.get_orientation(45, 130) == expected
+
+
+def test_SingleAxisTrackerMount_constructor(single_axis_tracker_mount):
+    expected = dict(axis_tilt=10, axis_azimuth=170, max_angle=45,
+                    backtrack=False, gcr=0.4, cross_axis_tilt=-5)
+    for attr_name, expected_value in expected.items():
+        assert getattr(single_axis_tracker_mount, attr_name) == expected_value
+
+
+def test_SingleAxisTrackerMount_get_orientation(single_axis_tracker_mount):
+    expected = {'surface_tilt': 19.29835284, 'surface_azimuth': 229.7643755}
+    actual = single_axis_tracker_mount.get_orientation(45, 190)
+    for key, expected_value in expected.items():
+        err_msg = f"{key} value incorrect"
+        assert actual[key] == pytest.approx(expected_value), err_msg
+
+
 def test_dc_ohms_from_percent():
     expected = .1425
     out = pvsystem.dc_ohms_from_percent(38, 8, 3, 1, 1)
@@ -2183,7 +2212,8 @@ def test_Array_dc_ohms_from_percent(mocker):
 
     expected = .1425
 
-    array = pvsystem.Array(array_losses_parameters={'dc_ohmic_percent': 3},
+    array = pvsystem.Array(pvsystem.FixedMount(0, 180),
+                           array_losses_parameters={'dc_ohmic_percent': 3},
                            module_parameters={'I_mp_ref': 8,
                                               'V_mp_ref': 38})
     out = array.dc_ohms_from_percent()
@@ -2196,7 +2226,8 @@ def test_Array_dc_ohms_from_percent(mocker):
     )
     assert_allclose(out, expected)
 
-    array = pvsystem.Array(array_losses_parameters={'dc_ohmic_percent': 3},
+    array = pvsystem.Array(pvsystem.FixedMount(0, 180),
+                           array_losses_parameters={'dc_ohmic_percent': 3},
                            module_parameters={'Impo': 8,
                                               'Vmpo': 38})
     out = array.dc_ohms_from_percent()
@@ -2209,7 +2240,8 @@ def test_Array_dc_ohms_from_percent(mocker):
     )
     assert_allclose(out, expected)
 
-    array = pvsystem.Array(array_losses_parameters={'dc_ohmic_percent': 3},
+    array = pvsystem.Array(pvsystem.FixedMount(0, 180),
+                           array_losses_parameters={'dc_ohmic_percent': 3},
                            module_parameters={'Impp': 8,
                                               'Vmpp': 38})
     out = array.dc_ohms_from_percent()
@@ -2230,7 +2262,8 @@ def test_Array_dc_ohms_from_percent(mocker):
                               '{"V_mp_ref", "I_mp_Ref"}, '
                               '{"Vmpo", "Impo"}, or '
                               '{"Vmpp", "Impp"}.')):
-        array = pvsystem.Array(array_losses_parameters={'dc_ohmic_percent': 3})
+        array = pvsystem.Array(pvsystem.FixedMount(0, 180),
+                               array_losses_parameters={'dc_ohmic_percent': 3})
         out = array.dc_ohms_from_percent()
 
 
@@ -2261,7 +2294,7 @@ def test_PVSystem_temperature_deprecated(funcname):
 ])
 def test_Array_temperature_missing_parameters(model, keys):
     # test that a nice error is raised when required temp params are missing
-    array = pvsystem.Array()
+    array = pvsystem.Array(pvsystem.FixedMount(0, 180))
     index = pd.date_range('2019-01-01', freq='h', periods=5)
     temps = pd.Series(25, index)
     irrads = pd.Series(1000, index)
diff --git a/pvlib/tests/test_tracking.py b/pvlib/tests/test_tracking.py
index ee4a9b8..9a589e4 100644
--- a/pvlib/tests/test_tracking.py
+++ b/pvlib/tests/test_tracking.py
@@ -6,8 +6,9 @@ import pytest
 from numpy.testing import assert_allclose
 
 import pvlib
-from pvlib import tracking, pvsystem
+from pvlib import tracking
 from .conftest import DATA_DIR, assert_frame_equal
+from pvlib._deprecation import pvlibDeprecationWarning
 
 SINGLEAXIS_COL_ORDER = ['tracker_theta', 'aoi',
                         'surface_azimuth', 'surface_tilt']
@@ -289,51 +290,11 @@ def test_low_sun_angles():
         assert_allclose(expected[k], v)
 
 
-def test_SingleAxisTracker_creation():
-    system = tracking.SingleAxisTracker(max_angle=45,
-                                        gcr=.25,
-                                        module='blah',
-                                        inverter='blarg')
-
-    assert system.max_angle == 45
-    assert system.gcr == .25
-    assert system.arrays[0].module == 'blah'
-    assert system.inverter == 'blarg'
-
-
-def test_SingleAxisTracker_one_array_only():
-    system = tracking.SingleAxisTracker(
-        arrays=[pvsystem.Array(
-            module='foo',
-            surface_tilt=None,
-            surface_azimuth=None
-        )]
-    )
-    assert system.arrays[0].module == 'foo'
-    with pytest.raises(ValueError,
-                       match="SingleAxisTracker does not support "
-                             r"multiple arrays\."):
-        tracking.SingleAxisTracker(
-            arrays=[pvsystem.Array(module='foo'),
-                    pvsystem.Array(module='bar')]
-        )
-    with pytest.raises(ValueError,
-                       match="Array must not have surface_tilt "):
-        tracking.SingleAxisTracker(arrays=[pvsystem.Array(module='foo')])
-    with pytest.raises(ValueError,
-                       match="Array must not have surface_tilt "):
-        tracking.SingleAxisTracker(
-            arrays=[pvsystem.Array(surface_azimuth=None)])
-    with pytest.raises(ValueError,
-                       match="Array must not have surface_tilt "):
-        tracking.SingleAxisTracker(
-            arrays=[pvsystem.Array(surface_tilt=None)])
-
-
 def test_SingleAxisTracker_tracking():
-    system = tracking.SingleAxisTracker(max_angle=90, axis_tilt=30,
-                                        axis_azimuth=180, gcr=2.0/7.0,
-                                        backtrack=True)
+    with pytest.warns(pvlibDeprecationWarning):
+        system = tracking.SingleAxisTracker(max_angle=90, axis_tilt=30,
+                                            axis_azimuth=180, gcr=2.0/7.0,
+                                            backtrack=True)
 
     apparent_zenith = pd.Series([30])
     apparent_azimuth = pd.Series([135])
@@ -353,9 +314,10 @@ def test_SingleAxisTracker_tracking():
     pvsyst_solar_height = 27.315
     pvsyst_axis_tilt = 20.
     pvsyst_axis_azimuth = 20.
-    pvsyst_system = tracking.SingleAxisTracker(
-        max_angle=60., axis_tilt=pvsyst_axis_tilt,
-        axis_azimuth=180+pvsyst_axis_azimuth, backtrack=False)
+    with pytest.warns(pvlibDeprecationWarning):
+        pvsyst_system = tracking.SingleAxisTracker(
+            max_angle=60., axis_tilt=pvsyst_axis_tilt,
+            axis_azimuth=180+pvsyst_axis_azimuth, backtrack=False)
     # the definition of azimuth is different from PYsyst
     apparent_azimuth = pd.Series([180+pvsyst_solar_azimuth])
     apparent_zenith = pd.Series([90-pvsyst_solar_height])
@@ -371,9 +333,10 @@ def test_SingleAxisTracker_tracking():
 
 # see test_irradiance for more thorough testing
 def test_get_aoi():
-    system = tracking.SingleAxisTracker(max_angle=90, axis_tilt=30,
-                                        axis_azimuth=180, gcr=2.0/7.0,
-                                        backtrack=True)
+    with pytest.warns(pvlibDeprecationWarning):
+        system = tracking.SingleAxisTracker(max_angle=90, axis_tilt=30,
+                                            axis_azimuth=180, gcr=2.0/7.0,
+                                            backtrack=True)
     surface_tilt = np.array([30, 0])
     surface_azimuth = np.array([90, 270])
     solar_zenith = np.array([70, 10])
@@ -385,9 +348,10 @@ def test_get_aoi():
 
 
 def test_get_irradiance():
-    system = tracking.SingleAxisTracker(max_angle=90, axis_tilt=30,
-                                        axis_azimuth=180, gcr=2.0/7.0,
-                                        backtrack=True)
+    with pytest.warns(pvlibDeprecationWarning):
+        system = tracking.SingleAxisTracker(max_angle=90, axis_tilt=30,
+                                            axis_azimuth=180, gcr=2.0/7.0,
+                                            backtrack=True)
     times = pd.date_range(start='20160101 1200-0700',
                           end='20160101 1800-0700', freq='6H')
     # latitude=32, longitude=-111
@@ -431,9 +395,10 @@ def test_get_irradiance():
 
 
 def test_SingleAxisTracker___repr__():
-    system = tracking.SingleAxisTracker(
-        max_angle=45, gcr=.25, module='blah', inverter='blarg',
-        temperature_model_parameters={'a': -3.56})
+    with pytest.warns(pvlibDeprecationWarning):
+        system = tracking.SingleAxisTracker(
+            max_angle=45, gcr=.25, module='blah', inverter='blarg',
+            temperature_model_parameters={'a': -3.56})
     expected = """SingleAxisTracker:
   axis_tilt: 0
   axis_azimuth: 0
@@ -444,16 +409,14 @@ def test_SingleAxisTracker___repr__():
   name: None
   Array:
     name: None
-    surface_tilt: None
-    surface_azimuth: None
+    mount: SingleAxisTrackerMount(axis_tilt=0, axis_azimuth=0, max_angle=45, backtrack=True, gcr=0.25, cross_axis_tilt=0.0, racking_model=None, module_height=None)
     module: blah
     albedo: 0.25
-    racking_model: None
     module_type: None
     temperature_model_parameters: {'a': -3.56}
     strings: 1
     modules_per_string: 1
-  inverter: blarg"""
+  inverter: blarg"""  # noqa: E501
     assert system.__repr__() == expected