Repository: techmatters/terraso-backend

Issue:
feat: hook up data based soil matches query to real soil ID algorithm
## Description
Depends on #1266 and #1269. Size reflects that there will hopefully be less work for this once #1269 is done.


Discussion:

Resolving patch:
diff --git a/terraso_backend/apps/soil_id/graphql/soil_id.py b/terraso_backend/apps/soil_id/graphql/soil_id.py
--- a/terraso_backend/apps/soil_id/graphql/soil_id.py
+++ b/terraso_backend/apps/soil_id/graphql/soil_id.py
@@ -14,14 +14,16 @@
 # along with this program. If not, see https://www.gnu.org/licenses/.
 
 import graphene
-from soil_id.us_soil import list_soils
+from soil_id.us_soil import list_soils, rank_soils
 
 from apps.soil_id.graphql.soil_data import (
     DepthDependentSoilDataNode,
     DepthInterval,
     DepthIntervalInput,
+    SoilDataNode,
 )
 from apps.soil_id.models.depth_dependent_soil_data import DepthDependentSoilData
+from apps.soil_id.models.soil_data import SoilData
 
 
 class EcologicalSite(graphene.ObjectType):
@@ -137,6 +139,7 @@ class SoilIdInputData(graphene.InputObjectType):
     """Soil data provided to the soil ID algorithm."""
 
     slope = graphene.Float()
+    surface_cracks = SoilDataNode.surface_cracks_enum()
     depth_dependent_data = graphene.List(
         graphene.NonNull(SoilIdInputDepthDependentData), required=True
     )
@@ -238,7 +241,7 @@ def resolve_soil_data(soil_match):
     )
 
 
-def resolve_soil_match(soil_match):
+def resolve_soil_info(soil_match: dict):
     soil_id = soil_match["id"]
     site_data = soil_match["site"]["siteData"]
     ecological_site = soil_match["esd"]["ESD"]
@@ -251,65 +254,157 @@ def resolve_soil_match(soil_match):
             url=ecological_site["esd_url"],
         )
 
+    return SoilInfo(
+        soil_series=SoilSeries(
+            name=soil_id["component"],
+            taxonomy_subgroup=site_data["taxsubgrp"],
+            description=soil_match["site"]["siteDescription"],
+            full_description_url=site_data["sdeURL"],
+        ),
+        land_capability_class=LandCapabilityClass(
+            capability_class=site_data["nirrcapcl"],
+            sub_class=site_data["nirrcapscl"],
+        ),
+        ecological_site=ecological_site,
+        soil_data=resolve_soil_data(soil_match),
+    )
+
+
+def resolve_soil_match_info(score: float, rank: str):
+    return SoilMatchInfo(score=score, rank=int(rank) - 1)
+
+
+def resolve_location_based_soil_match(soil_match: dict):
+    soil_id = soil_match["id"]
+    site_data = soil_match["site"]["siteData"]
+
     return LocationBasedSoilMatch(
         data_source=site_data["dataSource"],
         distance_to_nearest_map_unit_m=site_data["minCompDistance"],
-        match=SoilMatchInfo(score=soil_id["score_loc"], rank=int(soil_id["rank_loc"]) - 1),
-        soil_info=SoilInfo(
-            soil_series=SoilSeries(
-                name=soil_id["component"],
-                taxonomy_subgroup=site_data["taxsubgrp"],
-                description=soil_match["site"]["siteDescription"],
-                full_description_url=site_data["sdeURL"],
-            ),
-            land_capability_class=LandCapabilityClass(
-                capability_class=site_data["nirrcapcl"],
-                sub_class=site_data["nirrcapscl"],
-            ),
-            ecological_site=ecological_site,
-            soil_data=resolve_soil_data(soil_match),
-        ),
+        match=resolve_soil_match_info(soil_id["score_loc"], soil_id["rank_loc"]),
+        soil_info=resolve_soil_info(soil_match),
     )
 
 
 def resolve_location_based_soil_matches(_parent, _info, latitude: float, longitude: float):
-    result = list_soils(lat=latitude, lon=longitude, plot_id=None, site_calc=False)
+    result = list_soils(lat=latitude, lon=longitude)
 
     if isinstance(result, str):
         return None
 
     matches = []
-    for match in result["soilList"]:
+    for match in result.soil_list_json["soilList"]:
         if match["id"]["rank_loc"] != "Not Displayed":
-            matches.append(resolve_soil_match(match))
+            matches.append(resolve_location_based_soil_match(match))
     return LocationBasedSoilMatches(matches=matches)
 
 
+def resolve_data_based_soil_match(soil_matches: list[dict], ranked_match: dict):
+    soil_match = [
+        match
+        for match in soil_matches
+        if int(match["site"]["siteData"]["componentID"]) == ranked_match["componentID"]
+    ][0]
+    site_data = soil_match["site"]["siteData"]
+
+    return DataBasedSoilMatch(
+        data_source=site_data["dataSource"],
+        distance_to_nearest_map_unit_m=site_data["minCompDistance"],
+        location_match=resolve_soil_match_info(ranked_match["score_loc"], ranked_match["rank_loc"]),
+        data_match=resolve_soil_match_info(ranked_match["score_data"], ranked_match["rank_data"]),
+        combined_match=resolve_soil_match_info(
+            ranked_match["score_data_loc"], ranked_match["rank_data_loc"]
+        ),
+        soil_info=resolve_soil_info(soil_match),
+    )
+
+
+def parse_texture(texture: DepthDependentSoilData.Texture):
+    return texture.value.replace("_", " ").lower()
+
+
+def parse_rock_fragment_volume(rock_fragment_volume: DepthDependentSoilData.RockFragmentVolume):
+    if rock_fragment_volume == DepthDependentSoilData.RockFragmentVolume.VOLUME_0_1:
+        return "0-1%"
+    elif rock_fragment_volume == DepthDependentSoilData.RockFragmentVolume.VOLUME_1_15:
+        return "1-15%"
+    elif rock_fragment_volume == DepthDependentSoilData.RockFragmentVolume.VOLUME_15_35:
+        return "15-35%"
+    elif rock_fragment_volume == DepthDependentSoilData.RockFragmentVolume.VOLUME_35_60:
+        return "35-60%"
+    else:
+        return ">60%"
+
+
+def parse_color_LAB(color_LAB):
+    return [color_LAB.L, color_LAB.A, color_LAB.B]
+
+
+def parse_surface_cracks(surface_cracks: SoilData.SurfaceCracks):
+    if surface_cracks is None:
+        return None
+    return surface_cracks == SoilData.SurfaceCracks.DEEP_VERTICAL_CRACKING
+
+
+def parse_rank_soils_input_data(data: SoilIdInputData):
+    # TODO: pass in values for elevation and bedrock
+    inputs = {
+        "soilHorizon": [],
+        "horizonDepth": [],
+        "rfvDepth": [],
+        "lab_Color": [],
+        "pSlope": data.slope,
+        "pElev": None,  # meters
+        "bedrock": None,
+        "cracks": parse_surface_cracks(data.surface_cracks),
+    }
+
+    depths = data.depth_dependent_data
+    if len(depths) > 0 and depths[0].depth_interval.start != 0:
+        inputs["horizonDepth"].append(depths[0].depth_interval.end)
+        inputs["soilHorizon"].append(None)
+        inputs["rfvDepth"].append(None)
+        inputs["lab_Color"].append(None)
+        depths = depths[1:]
+
+    for depth in depths:
+        inputs["horizonDepth"].append(depth.depth_interval.end)
+        inputs["soilHorizon"].append(parse_texture(depth.texture))
+        inputs["rfvDepth"].append(parse_rock_fragment_volume(depth.rock_fragment_volume))
+        inputs["lab_Color"].append(parse_color_LAB(depth.color_LAB))
+
+    return inputs
+
+
 # to be replaced by actual algorithm output
 def resolve_data_based_soil_matches(
     _parent, _info, latitude: float, longitude: float, data: SoilIdInputData
 ):
-    return DataBasedSoilMatches(
-        matches=[
-            DataBasedSoilMatch(
-                data_source="SSURGO",
-                distance_to_nearest_map_unit_m=0.0,
-                location_match=SoilMatchInfo(score=1.0, rank=0),
-                data_match=SoilMatchInfo(score=0.2, rank=1),
-                combined_match=SoilMatchInfo(score=0.6, rank=1),
-                soil_info=sample_soil_infos[0],
-            ),
-            DataBasedSoilMatch(
-                data_source="STATSGO",
-                distance_to_nearest_map_unit_m=50.0,
-                location_match=SoilMatchInfo(score=0.5, rank=1),
-                data_match=SoilMatchInfo(score=0.75, rank=0),
-                combined_match=SoilMatchInfo(score=0.625, rank=0),
-                soil_info=sample_soil_infos[1],
-            ),
-        ]
+    list_result = list_soils(lat=latitude, lon=longitude)
+    if isinstance(list_result, str):
+        return None
+
+    result = rank_soils(
+        lat=latitude,
+        lon=longitude,
+        list_output_data=list_result,
+        **parse_rank_soils_input_data(data)
     )
 
+    ranked_matches = []
+    for ranked_match in result["soilRank"]:
+        rankValues = [
+            ranked_match["rank_loc"],
+            ranked_match["rank_data"],
+            ranked_match["rank_data_loc"],
+        ]
+        if all([value != "Not Displayed" for value in rankValues]):
+            ranked_matches.append(
+                resolve_data_based_soil_match(list_result.soil_list_json["soilList"], ranked_match)
+            )
+
+    return DataBasedSoilMatches(matches=ranked_matches)
+
 
 class SoilId(graphene.ObjectType):
     """Soil ID algorithm queries."""
@@ -327,7 +422,6 @@ class SoilId(graphene.ObjectType):
         longitude=graphene.Float(required=True),
         data=graphene.Argument(SoilIdInputData, required=True),
         resolver=resolve_data_based_soil_matches,
-        required=True,
     )
 
 


Test patch:
diff --git a/terraso_backend/tests/graphql/test_soil_id.py b/terraso_backend/tests/graphql/test_soil_id.py
--- a/terraso_backend/tests/graphql/test_soil_id.py
+++ b/terraso_backend/tests/graphql/test_soil_id.py
@@ -148,8 +148,8 @@ def test_data_based_soil_matches_endpoint(client):
     response = graphql_query(
         DATA_BASED_MATCHES_QUERY,
         variables={
-            "latitude": 0.0,
-            "longitude": 0.0,
+            "latitude": 33.81246789,
+            "longitude": -101.9733687,
             "data": {
                 "slope": 0.5,
                 "depthDependentData": [
diff --git a/terraso_backend/tests/soil_id/test_soil_id.py b/terraso_backend/tests/soil_id/test_soil_id.py
--- a/terraso_backend/tests/soil_id/test_soil_id.py
+++ b/terraso_backend/tests/soil_id/test_soil_id.py
@@ -17,5 +17,5 @@
 
 
 def test_soil_list():
-    list_soils(-101.9733687, 33.81246789, None, False)
+    list_soils(-101.9733687, 33.81246789)
     assert True