Repository: NREL/hescore-hpxml

Issue:
Multiple ducts translation
**Describe the bug**
If there are two ducts located in the same space type, it seems that they get combined into one duct. When both ducts are either insulated or uninsulated, it's not a problem. But when one duct is insulated and the other duct is uninsulated, combining these ducts into one duct can be a problem. For example, 
```
<AirDistribution>
    <Ducts>
        <DuctInsulationMaterial>
            <None/>
        </DuctInsulationMaterial>
        <DuctLocation>attic</DuctLocation>
        <FractionDuctArea>0.5</FractionDuctArea>
    </Ducts>
    <Ducts>
        <DuctInsulationMaterial>
            <Unknown/>
        </DuctInsulationMaterial>
        <DuctLocation>attic</DuctLocation>
        <FractionDuctArea>0.5</FractionDuctArea>
    </Ducts>
</AirDistribution>
```
these ducts are translated into
```
"hvac_distribution": [
    {
        "name": "duct1",
        "location": "uncond_attic",
        "fraction": 100,
        "insulated": true,
        "sealed": false
    }
]
```


**To Reproduce**
Please see the attched HPXML
[hescore_min_v3_duct_test_attic.xml.txt](https://github.com/NREL/hescore-hpxml/files/7281567/hescore_min_v3_duct_test_attic.xml.txt)


**Expected behavior**
I think what we expect to see is
```
"hvac_distribution": [
    {
        "name": "duct1",
        "location": "uncond_attic",
        "fraction": 50,
        "insulated": false,
        "sealed": false
    },
    {
        "name": "duct2",
        "location": "uncond_attic",
        "fraction": 50,
        "insulated": true,
        "sealed": false
    }
]
```

**Environment (please complete the following information):**
 - OS: Windows
 - Python Version: 3.9.2


Discussion:

Patch:
diff --git a/docs/source/translation/hvac_distribution.rst b/docs/source/translation/hvac_distribution.rst
index 22b0b4fa..7063d32b 100644
--- a/docs/source/translation/hvac_distribution.rst
+++ b/docs/source/translation/hvac_distribution.rst
@@ -73,8 +73,8 @@ following mapping.
 Duct Fractions
 **************
 
-For each ``Ducts`` element in an air distribution system the ``FracDuctArea`` is summed by
-HEScore :ref:`duct location <ductlocationmapping>`.
+For each ``Ducts`` element with the same insulation level in an air distribution system 
+the ``FracDuctArea`` is summed by HEScore :ref:`duct location <ductlocationmapping>`.
 
 Duct Insulation
 ***************
diff --git a/examples/hescore_min.json b/examples/hescore_min.json
index a631cf85..0172e08b 100644
--- a/examples/hescore_min.json
+++ b/examples/hescore_min.json
@@ -23,10 +23,10 @@
       "zone_roof": [
         {
           "roof_name": "roof1",
-          "ceiling_area": 1200.0,
           "roof_assembly_code": "rfwf00co",
           "roof_color": "medium",
           "roof_type": "vented_attic",
+          "ceiling_area": 1200.0,
           "ceiling_assembly_code": "ecwf49",
           "zone_skylight": {
             "skylight_area": 11.0,
diff --git a/examples/hescore_min_assembly_rvalue.json b/examples/hescore_min_assembly_rvalue.json
index c4ed32f7..cd26001b 100644
--- a/examples/hescore_min_assembly_rvalue.json
+++ b/examples/hescore_min_assembly_rvalue.json
@@ -23,10 +23,10 @@
       "zone_roof": [
         {
           "roof_name": "roof1",
-          "ceiling_area": 1200.0,
           "roof_assembly_code": "rfwf00co",
           "roof_color": "medium",
           "roof_type": "vented_attic",
+          "ceiling_area": 1200.0,
           "ceiling_assembly_code": "ecwf38",
           "zone_skylight": {
             "skylight_area": 11.0,
diff --git a/examples/house1-v2-2.xml b/examples/house1-v2-2.xml
index 595bf6ee..029d2176 100644
--- a/examples/house1-v2-2.xml
+++ b/examples/house1-v2-2.xml
@@ -57,6 +57,7 @@
                                     <NominalRValue>13</NominalRValue>
                                 </Layer>
                             </AtticFloorInsulation>
+                            <Area>810</Area>
                         </Attic>
                     </Attics>
                 </AtticAndRoof>
diff --git a/examples/house1.json b/examples/house1.json
index 816a5ac2..7c632604 100644
--- a/examples/house1.json
+++ b/examples/house1.json
@@ -23,10 +23,10 @@
       "zone_roof": [
         {
           "roof_name": "roof1",
-          "ceiling_area": 810,
           "roof_assembly_code": "rfrb00co",
           "roof_color": "dark",
           "roof_type": "vented_attic",
+          "ceiling_area": 810.0,
           "ceiling_assembly_code": "ecwf13",
           "zone_skylight": {
             "skylight_area": 0
diff --git a/examples/house2.json b/examples/house2.json
index 177d1b17..bb8e18af 100644
--- a/examples/house2.json
+++ b/examples/house2.json
@@ -23,10 +23,10 @@
       "zone_roof": [
         {
           "roof_name": "roof1",
-          "ceiling_area": 810,
           "roof_assembly_code": "rfrb00co",
           "roof_color": "dark",
           "roof_type": "vented_attic",
+          "ceiling_area": 810.0,
           "ceiling_assembly_code": "ecwf13",
           "zone_skylight": {
             "skylight_area": 0
diff --git a/examples/house3.json b/examples/house3.json
index 7964023c..7f2ec517 100644
--- a/examples/house3.json
+++ b/examples/house3.json
@@ -23,10 +23,10 @@
       "zone_roof": [
         {
           "roof_name": "roof1",
-          "roof_area": 679.0,
           "roof_assembly_code": "rfwf15rc",
           "roof_color": "medium",
           "roof_type": "cath_ceiling",
+          "roof_area": 679.0,
           "zone_skylight": {
             "skylight_area": 0
           }
diff --git a/examples/house4.json b/examples/house4.json
index fcee6bed..df464bf9 100644
--- a/examples/house4.json
+++ b/examples/house4.json
@@ -23,10 +23,10 @@
       "zone_roof": [
         {
           "roof_name": "roof1",
-          "ceiling_area": 1306.5,
           "roof_assembly_code": "rfwf00co",
           "roof_color": "white",
           "roof_type": "vented_attic",
+          "ceiling_area": 1306.5,
           "ceiling_assembly_code": "ecwf09",
           "zone_skylight": {
             "skylight_area": 12.0,
@@ -37,10 +37,10 @@
         },
         {
           "roof_name": "roof2",
-          "roof_area": 600.0,
           "roof_assembly_code": "rfwf25co",
           "roof_color": "white",
           "roof_type": "cath_ceiling",
+          "roof_area": 600.0,
           "zone_skylight": {
             "skylight_area": 0
           }
diff --git a/examples/house5.json b/examples/house5.json
index 420b0b5a..41a03883 100644
--- a/examples/house5.json
+++ b/examples/house5.json
@@ -23,10 +23,10 @@
       "zone_roof": [
         {
           "roof_name": "roof1",
-          "ceiling_area": 898.0,
           "roof_assembly_code": "rfwf30wo",
           "roof_color": "medium",
           "roof_type": "vented_attic",
+          "ceiling_area": 898.0,
           "ceiling_assembly_code": "ecwf00",
           "zone_skylight": {
             "skylight_area": 0
diff --git a/examples/house6.json b/examples/house6.json
index 083fc9f6..e7aa0e5a 100644
--- a/examples/house6.json
+++ b/examples/house6.json
@@ -23,10 +23,10 @@
       "zone_roof": [
         {
           "roof_name": "roof1",
-          "ceiling_area": 1000.0,
           "roof_assembly_code": "rfwf00tg",
           "roof_color": "medium",
           "roof_type": "vented_attic",
+          "ceiling_area": 1000.0,
           "ceiling_assembly_code": "ecwf49",
           "zone_skylight": {
             "skylight_area": 24.0,
@@ -37,10 +37,10 @@
         },
         {
           "roof_name": "roof2",
-          "roof_area": 843.0,
           "roof_assembly_code": "rfwf25wo",
           "roof_color": "medium",
           "roof_type": "cath_ceiling",
+          "roof_area": 843.0,
           "zone_skylight": {
             "skylight_area": 0
           }
diff --git a/examples/house7.json b/examples/house7.json
index 6901be11..b5f9c319 100644
--- a/examples/house7.json
+++ b/examples/house7.json
@@ -24,10 +24,10 @@
       "zone_roof": [
         {
           "roof_name": "roof1",
-          "roof_area": 900.0,
           "roof_assembly_code": "rfwf19tg",
           "roof_color": "medium",
           "roof_type": "cath_ceiling",
+          "roof_area": 900.0,
           "zone_skylight": {
             "skylight_area": 0
           }
diff --git a/examples/house8.json b/examples/house8.json
index 94438e1c..d1ff91bd 100644
--- a/examples/house8.json
+++ b/examples/house8.json
@@ -23,10 +23,10 @@
       "zone_roof": [
         {
           "roof_name": "roof1",
-          "ceiling_area": 1000.0,
           "roof_assembly_code": "rfwf00tg",
           "roof_color": "medium",
           "roof_type": "vented_attic",
+          "ceiling_area": 1000.0,
           "ceiling_assembly_code": "ecwf25",
           "zone_skylight": {
             "skylight_area": 0
@@ -34,10 +34,10 @@
         },
         {
           "roof_name": "roof2",
-          "roof_area": 843.0,
           "roof_assembly_code": "rfwf25wo",
           "roof_color": "medium",
           "roof_type": "cath_ceiling",
+          "roof_area": 843.0,
           "zone_skylight": {
             "skylight_area": 0
           }
diff --git a/examples/townhouse_walls.json b/examples/townhouse_walls.json
index 5de353b8..592c7aae 100644
--- a/examples/townhouse_walls.json
+++ b/examples/townhouse_walls.json
@@ -24,10 +24,10 @@
       "zone_roof": [
         {
           "roof_name": "roof1",
-          "ceiling_area": 1200.0,
           "roof_assembly_code": "rfwf00co",
           "roof_color": "medium",
           "roof_type": "vented_attic",
+          "ceiling_area": 1200.0,
           "ceiling_assembly_code": "ecwf49",
           "zone_skylight": {
             "skylight_area": 11.0,
diff --git a/hescorehpxml/base.py b/hescorehpxml/base.py
index 547d9add..103d8605 100644
--- a/hescorehpxml/base.py
+++ b/hescorehpxml/base.py
@@ -596,7 +596,10 @@ class HPXMLtoHEScoreTranslatorBase(object):
 
         duct_fracs_by_hescore_duct_loc = defaultdict(float)
         hescore_duct_loc_has_insulation = defaultdict(bool)
-        for duct_el in self.xpath(airdist_el, 'h:Ducts', aslist=True):
+        duct_locs = defaultdict(str)
+        for idx, duct_el in enumerate(self.xpath(airdist_el, 'h:Ducts', aslist=True)):
+            # Duct Identifier
+            duct_id = f'duct{idx}'
 
             # Duct Location
             hpxml_duct_location = self.xpath(duct_el, 'h:DuctLocation/text()')
@@ -605,15 +608,16 @@ class HPXMLtoHEScoreTranslatorBase(object):
             if hescore_duct_location is None:
                 raise TranslationError('No comparable duct location in HEScore: %s' % hpxml_duct_location)
 
+            duct_locs[duct_id] = hescore_duct_location
+
             # Fraction of Duct Area
             frac_duct_area = float(self.xpath(duct_el, 'h:FractionDuctArea/text()', raise_err=True))
-            duct_fracs_by_hescore_duct_loc[hescore_duct_location] += frac_duct_area
+            duct_fracs_by_hescore_duct_loc[duct_id] = frac_duct_area
 
             # Duct Insulation
             duct_has_ins = self.xpath(duct_el, 'h:DuctInsulationRValue > 0 or h:DuctInsulationThickness > 0 or\
                                       count(h:DuctInsulationMaterial[not(h:None)]) > 0')
-            hescore_duct_loc_has_insulation[hescore_duct_location] = \
-                hescore_duct_loc_has_insulation[hescore_duct_location] or duct_has_ins
+            hescore_duct_loc_has_insulation[duct_id] = duct_has_ins
 
         # Renormalize duct fractions so they add up to one (handles supply/return method if both are specified)
         total_duct_frac = sum(duct_fracs_by_hescore_duct_loc.values())
@@ -624,11 +628,19 @@ class HPXMLtoHEScoreTranslatorBase(object):
         # Gather the ducts by type
         hvac_distribution['duct'] = []
         hvacd_sortlist = []
-        for duct_loc, duct_frac in list(duct_fracs_by_hescore_duct_loc.items()):
-            hvacd = {}
-            hvacd['location'] = duct_loc
-            hvacd['fraction'] = duct_frac
-            hvacd_sortlist.append(hvacd)
+        for duct_id, duct_frac in list(duct_fracs_by_hescore_duct_loc.items()):
+            has_same_loc_and_ins = False
+            for d in hvacd_sortlist:
+                if d['location'] == duct_locs[duct_id] and d['insulated'] == hescore_duct_loc_has_insulation[duct_id]:
+                    d['fraction'] += duct_frac
+                    has_same_loc_and_ins = True
+
+            if not has_same_loc_and_ins:
+                hvacd = {}
+                hvacd['location'] = duct_locs[duct_id]
+                hvacd['fraction'] = duct_frac
+                hvacd['insulated'] = hescore_duct_loc_has_insulation[duct_id]
+                hvacd_sortlist.append(hvacd)
 
         # Sort them
         hvacd_sortlist.sort(key=lambda x: (x['fraction'], x['location']), reverse=True)
@@ -640,7 +652,7 @@ class HPXMLtoHEScoreTranslatorBase(object):
             hvacd_out['name'] = 'duct%d' % i
             hvacd_out['location'] = hvacd['location']
             hvacd_out['fraction'] = hvacd['fraction'] / sum_of_top_3_fractions
-            hvacd_out['insulated'] = hescore_duct_loc_has_insulation[hvacd['location']]
+            hvacd_out['insulated'] = hvacd['insulated']
             hvac_distribution['duct'].append(hvacd_out)
 
         # Make sure the fractions add up to 1


Test patch:
diff --git a/tests/test_schema.py b/tests/test_schema.py
index d303e5f0..8480e6db 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -154,10 +154,10 @@ def test_invalid_building_zone(hpxml_filebase):
     js['building']['zone'].append(zone)
     errors = get_error_messages(js, js_schema)
     if hpxml_filebase == 'townhouse_walls':
-        assert any(error.startswith("[{'zone_roof': [{'roof_name': 'roof1', 'ceiling_area': 1200.0") and
+        assert any(error.startswith("[{'zone_roof': [{'roof_name': 'roof1', 'roof_assembly_code': 'rfwf00co'") and
                    error.endswith("is not of type 'object'") for error in errors)
     elif hpxml_filebase == 'house1':
-        assert any(error.startswith("[{'zone_roof': [{'roof_name': 'roof1', 'ceiling_area': 810") and
+        assert any(error.startswith("[{'zone_roof': [{'roof_name': 'roof1', 'roof_assembly_code': 'rfrb00co'") and
                    error.endswith("is not of type 'object'") for error in errors)
 
 
diff --git a/tests/test_translation.py b/tests/test_translation.py
index 71d6418f..76c3a007 100644
--- a/tests/test_translation.py
+++ b/tests/test_translation.py
@@ -1413,6 +1413,70 @@ class TestOtherHouses(unittest.TestCase, ComparatorBase):
         self.assertNotIn('leakage_to_outside', res['building']['systems']['hvac'][0]['hvac_distribution'])
         self.assertEqual(res['building']['systems']['hvac'][0]['hvac_distribution']['sealed'], False)
 
+    def test_ducts_insulation(self):
+        tr = self._load_xmlfile('hescore_min_v3')
+        E = self.element_maker()
+        duct = self.xpath('//h:AirDistribution/h:Ducts')
+        duct.addnext(
+            E.Ducts(
+                E.DuctType('supply'),
+                E.DuctInsulationRValue('4.0'),
+                E.DuctLocation('attic - vented'),
+                E.FractionDuctArea('0.55')
+            )
+        )
+        duct.addnext(
+            E.Ducts(
+                E.DuctType('supply'),
+                E.DuctInsulationRValue('0.0'),
+                E.DuctLocation('attic - vented'),
+                E.FractionDuctArea('0.2')
+            )
+        )
+        duct.addnext(
+            E.Ducts(
+                E.DuctType('return'),
+                E.DuctInsulationRValue('4.0'),
+                E.DuctLocation('attic - vented'),
+                E.FractionDuctArea('0.55')
+            )
+        )
+        duct.addnext(
+            E.Ducts(
+                E.DuctType('return'),
+                E.DuctInsulationRValue('0.0'),
+                E.DuctLocation('attic - vented'),
+                E.FractionDuctArea('0.2')
+            )
+        )
+        duct.addnext(
+            E.Ducts(
+                E.DuctType('supply'),
+                E.DuctInsulationRValue('0.0'),
+                E.DuctLocation('living space'),
+                E.FractionDuctArea('0.25')
+            )
+        )
+        duct.addnext(
+            E.Ducts(
+                E.DuctType('return'),
+                E.DuctInsulationRValue('0.0'),
+                E.DuctLocation('living space'),
+                E.FractionDuctArea('0.25')
+            )
+        )
+        duct.getparent().remove(duct)
+        res = tr.hpxml_to_hescore()
+        self.assertEqual(res['building']['systems']['hvac'][0]['hvac_distribution']['duct'][0]['location'], 'uncond_attic')  # noqa E501
+        self.assertEqual(res['building']['systems']['hvac'][0]['hvac_distribution']['duct'][0]['fraction'], 0.55)
+        self.assertEqual(res['building']['systems']['hvac'][0]['hvac_distribution']['duct'][0]['insulated'], True)
+        self.assertEqual(res['building']['systems']['hvac'][0]['hvac_distribution']['duct'][1]['location'], 'cond_space')   # noqa E501
+        self.assertEqual(res['building']['systems']['hvac'][0]['hvac_distribution']['duct'][1]['fraction'], 0.25)
+        self.assertEqual(res['building']['systems']['hvac'][0]['hvac_distribution']['duct'][1]['insulated'], False)
+        self.assertEqual(res['building']['systems']['hvac'][0]['hvac_distribution']['duct'][2]['location'], 'uncond_attic')   # noqa E501
+        self.assertEqual(res['building']['systems']['hvac'][0]['hvac_distribution']['duct'][2]['fraction'], 0.2)
+        self.assertEqual(res['building']['systems']['hvac'][0]['hvac_distribution']['duct'][2]['insulated'], False)
+
 
 class TestInputOutOfBounds(unittest.TestCase, ComparatorBase):