Repository: NREL/hescore-hpxml

Issue:
Read NumberOfPanels from PV in HPXML v3
**Is your feature request related to a problem? Please describe.**
The `NumberOfPanels` element was added in HPXML v3 (probably to support HEScore). Currently we're either reading from `MaxPowerOutput` or estimating the number of panels from `CollectorArea` because that input wasn't available in HPXML v2. Now that we have it, we should read it. 

**Describe the solution you'd like**
The following logic:

- If `MaxPowerOutput` is available, use that.
- Else if `NumberOfPanels` is available, use that.
- Else if `CollectorArea` is available, estimate number of panels from that as we do now.

cc @torstenglidden

Comments:

Patch:
diff --git a/docs/source/translation/generation.rst b/docs/source/translation/generation.rst
index b4b2c751..039ac1a1 100644
--- a/docs/source/translation/generation.rst
+++ b/docs/source/translation/generation.rst
@@ -4,55 +4,75 @@ Generation
 Solar Electric
 **************
 
-HEScore allows for a single photovoltaic system to be included as of v2016.
-In HPXML, multiple ``PVSystem`` elements can be specified to represent the PV systems on the house.
-The translator combines multiple systems and generates the appropriate HEScore inputs as follows:
+HEScore allows for a single photovoltaic system to be included as of v2016. In
+HPXML, multiple ``PVSystem`` elements can be specified to represent the PV
+systems on the house. The translator combines multiple systems and generates the
+appropriate HEScore inputs as follows:
 
 Capacity Known
 ==============
 
-If each ``PVSystem`` has a ``MaxPowerOutput``, this is true.
-If each ``PVSystem`` has a ``CollectorArea``, this is false.
-Preference is given to known capacity if both are available.
-Either a ``MaxPowerOutput`` must be specified for every ``PVSystem``
-or ``CollectorArea`` must be specified for every ``PVSystem``.
+If each ``PVSystem`` has a ``MaxPowerOutput``, this is true. If each
+``PVSystem`` has a ``NumberOfPanels`` or if each has ``CollectorArea``, this is
+false. Preference is given to known capacity if available. Either a
+``MaxPowerOutput`` must be specified for every ``PVSystem`` or ``CollectorArea``
+must be specified for every ``PVSystem``.
 
 DC Capacity
 ===========
 
-If each ``PVSystem`` has a ``MaxPowerOutput``, the system capacity is known.
-The ``system_capacity`` in HEScore is calculated by summing all the ``MaxPowerOutput`` elements in HPXML.
+If each ``PVSystem`` has a ``MaxPowerOutput``, the system capacity is known. The
+``system_capacity`` in HEScore is calculated by summing all the
+``MaxPowerOutput`` elements in HPXML.
 
 Number of Panels
 ================
 
-If ``MaxPowerOutput`` is missing from any ``PVSystem``,
-``CollectorArea`` is required on every PVSystem and the system capacity is not known.
-The number of panels is calculated by summing all the collector area, dividing by 17.6 sq.ft.,
-and rounding to the nearest whole number.
+If ``MaxPowerOutput`` is missing from any ``PVSystem``, the translator will
+check to see if every system has ``NumberOfPanels`` and calculate the total
+number of panels. 
+
+If ``NumberOfPanels`` isn't available on every system, the translator will look
+for ``CollectorArea`` on every PVSystem. The number of panels is calculated by
+summing all the collector area, dividing by 17.6 sq.ft., and rounding to the
+nearest whole number.
+
+Weighted Averages
+=================
+
+The below quantities are calculated using weighted averages. The weights used
+are in priority order:
+
+- ``MaxPowerOutput``
+- ``NumberOfPanels``
+- ``CollectorArea``
+
+Which is the same data elements used to determine the PV sizing inputs above.
 
 Year Installed
 ==============
 
-For each ``PVSystem`` the ``YearInverterManufactured`` and ``YearModulesManufactured`` element values are retrieved,
-and the greater of the two is assumed to be the year that system was installed.
-When there are multiple ``PVSystem`` elements, a capacity or area-weighted average of the assumed year installed
-is calculated and used.
+For each ``PVSystem`` the ``YearInverterManufactured`` and
+``YearModulesManufactured`` element values are retrieved, and the greater of the
+two is assumed to be the year that system was installed. When there are multiple
+``PVSystem`` elements, a weighted average is calculated and used.
 
 Panel Orientation (Azimuth)
 ===========================
 
-For each ``PVSystem`` the ``ArrayAzimuth`` (degrees clockwise from north) is retrieved.
-If ``ArrayAzimuth`` is not available, ``ArrayOrientation`` (north, northwest, etc) is converted into an azimuth.
-A capacity or area-weighted average azimuth is calculated and converted into the nearest cardinal direction
-(north, northwest, etc) for submission into the ``array_azimuth`` HEScore input (which expects a direction,
-not a numeric azimuth).
+For each ``PVSystem`` the ``ArrayAzimuth`` (degrees clockwise from north) is
+retrieved. If ``ArrayAzimuth`` is not available, ``ArrayOrientation`` (north,
+northwest, etc) is converted into an azimuth. A weighted average azimuth is
+calculated and converted into the nearest cardinal direction (north, northwest,
+etc) for submission into the ``array_azimuth`` HEScore input (which expects a
+direction, not a numeric azimuth).
 
 Panel Tilt
 ==========
-For each ``PVSystem`` the ``ArrayTilt`` (in degrees from horizontal) is retrieved.
-A capacity or area-weighted average tilt is calculated and submitted to the ``array_tilt`` HEScore input
-(which expects an enumeration, not a numeric tilt).
+
+For each ``PVSystem`` the ``ArrayTilt`` (in degrees from horizontal) is
+retrieved. A weighted average tilt is calculated and submitted to the
+``array_tilt`` HEScore input (which expects an enumeration, not a numeric tilt).
 The tilt is mapped to HEScore as follows:
 
 .. table:: Tilt mapping
diff --git a/hescorehpxml/base.py b/hescorehpxml/base.py
index a5f104d9..017db4c8 100644
--- a/hescorehpxml/base.py
+++ b/hescorehpxml/base.py
@@ -65,6 +65,10 @@ def round_to_nearest(x, vals, tails_tolerance=None):
     return nearest
 
 
+def weighted_average(items, weights):
+    return sum(item * weight for item, weight in zip(items, weights)) / sum(weights)
+
+
 class HPXMLtoHEScoreTranslatorBase(object):
     SCHEMA_DIR = None
 
@@ -2235,31 +2239,28 @@ class HPXMLtoHEScoreTranslatorBase(object):
 
         capacities = []
         collector_areas = []
+        n_panels_per_system = []
         years = []
         azimuths = []
         tilts = []
         for pvsystem in pvsystems:
 
-            max_power_output = self.xpath(pvsystem, 'h:MaxPowerOutput/text()')
-            if max_power_output:
-                capacities.append(float(max_power_output))  # W
-                collector_areas.append(None)
-            else:
-                capacities.append(None)
-                collector_area = self.xpath(pvsystem, 'h:CollectorArea/text()')
-                if collector_area:
-                    collector_areas.append(float(collector_area))
-                else:
-                    raise TranslationError('MaxPowerOutput or CollectorArea is required for every PVSystem.')
+            capacities.append(convert_to_type(float, self.xpath(pvsystem, 'h:MaxPowerOutput/text()')))
+            collector_areas.append(convert_to_type(float, self.xpath(pvsystem, 'h:CollectorArea/text()')))
+            n_panels_per_system.append(convert_to_type(int, self.xpath(pvsystem, 'h:NumberOfPanels/text()')))
+
+            if not (capacities[-1] or collector_areas[-1] or n_panels_per_system[-1]):
+                raise TranslationError(
+                    'MaxPowerOutput, NumberOfPanels, or CollectorArea is required for every PVSystem.'
+                )
 
-            manufacture_years = list(map(
-                int,
-                self.xpath(
+            manufacture_years = [
+                int(x) for x in self.xpath(
                     pvsystem,
                     'h:YearInverterManufactured/text()|h:YearModulesManufactured/text()',
-                    aslist=True)
-            )
-            )
+                    aslist=True
+                )
+            ]
             if manufacture_years:
                 years.append(max(manufacture_years))  # Use the latest year of manufacture
             else:
@@ -2283,31 +2284,25 @@ class HPXMLtoHEScoreTranslatorBase(object):
 
         if None not in capacities:
             solar_electric['capacity_known'] = True
-            total_capacity = sum(capacities)
-            solar_electric['system_capacity'] = total_capacity / 1000.
-            solar_electric['year'] = int(
-                old_div(sum([year * capacity for year, capacity in zip(years, capacities)]), total_capacity))
-            wtavg_azimuth = old_div(sum(
-                [az * capacity for az, capacity in zip(azimuths, capacities)]), total_capacity)
-            wtavg_tilt = sum(t * capacity for t, capacity in zip(tilts, capacities)) / total_capacity
+            solar_electric['system_capacity'] = sum(capacities) / 1000.
+            weights = capacities
+        elif None not in n_panels_per_system:
+            solar_electric['capacity_known'] = False
+            solar_electric['num_panels'] = sum(n_panels_per_system)
+            weights = n_panels_per_system
         elif None not in collector_areas:
             solar_electric['capacity_known'] = False
-            total_area = sum(collector_areas)
-            solar_electric['num_panels'] = int(python2round(total_area / 17.6))
-            solar_electric['year'] = int(sum([year * area for year, area in zip(years, collector_areas)]) / total_area)
-            wtavg_azimuth = old_div(sum(
-                [az * area for az, area in zip(azimuths, collector_areas)]
-            ), total_area)
-            wtavg_tilt = sum(t * area for t, area in zip(tilts, collector_areas)) / total_area
+            solar_electric['num_panels'] = int(round(sum(collector_areas) / 17.6))
+            weights = collector_areas
         else:
             raise TranslationError(
-                'Either a MaxPowerOutput must be specified for every PVSystem '
-                'or CollectorArea must be specified for every PVSystem.'
+                'Either a MaxPowerOutput or NumberOfPanels or CollectorArea must be specified for every PVSystem.'
             )
 
-        nearest_azimuth = self.get_nearest_azimuth(azimuth=wtavg_azimuth)
+        solar_electric['year'] = round(weighted_average(years, weights))
+        nearest_azimuth = self.get_nearest_azimuth(azimuth=weighted_average(azimuths, weights))
         solar_electric['array_azimuth'] = self.azimuth_to_hescore_orientation[nearest_azimuth]
-        solar_electric['array_tilt'] = self.get_nearest_tilt(wtavg_tilt)
+        solar_electric['array_tilt'] = self.get_nearest_tilt(weighted_average(tilts, weights))
 
         return generation
 


Test patch:
diff --git a/tests/test_translation.py b/tests/test_translation.py
index 4a5dd11a..cd222749 100644
--- a/tests/test_translation.py
+++ b/tests/test_translation.py
@@ -1642,6 +1642,7 @@ class TestPhotovoltaics(unittest.TestCase, ComparatorBase):
             capacity=5,
             inverter_year=2015,
             module_year=2013,
+            n_panels=None,
             collector_area=None):
         addns = self.translator.addns
 
@@ -1668,6 +1669,8 @@ class TestPhotovoltaics(unittest.TestCase, ComparatorBase):
             add_elem(pv_system, 'MaxPowerOutput', capacity * 1000)
         if collector_area is not None:
             add_elem(pv_system, 'CollectorArea', collector_area)
+        if n_panels is not None:
+            add_elem(pv_system, 'NumberOfPanels', n_panels)
         if inverter_year is not None:
             add_elem(pv_system, 'YearInverterManufactured', inverter_year)
         if module_year is not None:
@@ -1690,9 +1693,22 @@ class TestPhotovoltaics(unittest.TestCase, ComparatorBase):
         self._add_pv(capacity=None)
         self.assertRaisesRegex(
             TranslationError,
-            r'MaxPowerOutput or CollectorArea is required',
+            r'MaxPowerOutput, NumberOfPanels, or CollectorArea is required',
             tr.hpxml_to_hescore
-            )
+        )
+
+    def test_n_panels(self):
+        tr = self._load_xmlfile('hescore_min_v3')
+        self._add_pv(
+            capacity=None,
+            n_panels=12,
+            collector_area=1
+        )
+        hesd = tr.hpxml_to_hescore()
+        pv = hesd['building']['systems']['generation']['solar_electric']
+        self.assertFalse(pv['capacity_known'])
+        self.assertNotIn('system_capacity', list(pv.keys()))
+        self.assertEqual(pv['num_panels'], 12)
 
     def test_collector_area(self):
         tr = self._load_xmlfile('hescore_min')
@@ -1700,7 +1716,7 @@ class TestPhotovoltaics(unittest.TestCase, ComparatorBase):
         hesd = tr.hpxml_to_hescore()
         pv = hesd['building']['systems']['generation']['solar_electric']
         self.assertFalse(pv['capacity_known'])
-        self.assertNotIn('capacity', list(pv.keys()))
+        self.assertNotIn('system_capacity', list(pv.keys()))
         self.assertEqual(pv['num_panels'], 10)
 
     def test_orientation(self):
@@ -1761,7 +1777,7 @@ class TestPhotovoltaics(unittest.TestCase, ComparatorBase):
             module_year=2013)
         self.assertRaisesRegex(
             TranslationError,
-            r'Either a MaxPowerOutput must be specified for every PVSystem or CollectorArea',
+            r'Either a MaxPowerOutput or NumberOfPanels or CollectorArea must be specified',
             tr.hpxml_to_hescore
             )