diff --git a/terraso_backend/apps/graphql/schema/__init__.py b/terraso_backend/apps/graphql/schema/__init__.py index 7d092596e..002f7cb0b 100644 --- a/terraso_backend/apps/graphql/schema/__init__.py +++ b/terraso_backend/apps/graphql/schema/__init__.py @@ -41,6 +41,7 @@ SoilDataUpdateDepthIntervalMutation, SoilDataUpdateMutation, ) +from apps.soil_id.graphql.soil_id import soil_id from .audit_logs import AuditLogNode from .commons import TerrasoRelayNode @@ -141,6 +142,7 @@ class Query(graphene.ObjectType): sites = DjangoFilterConnectionField(SiteNode, required=True) audit_logs = DjangoFilterConnectionField(AuditLogNode) shared_resource = SharedResourceRelayNode.Field() + soil_id = soil_id from .shared_resources import resolve_shared_resource diff --git a/terraso_backend/apps/graphql/schema/schema.graphql b/terraso_backend/apps/graphql/schema/schema.graphql index b674b7013..84b78d283 100644 --- a/terraso_backend/apps/graphql/schema/schema.graphql +++ b/terraso_backend/apps/graphql/schema/schema.graphql @@ -4,6 +4,8 @@ schema { } type Query { + """Soil ID algorithm Queries""" + soilId: SoilId! group( """The ID of the object""" id: ID! @@ -92,6 +94,182 @@ type Query { sharedResource(shareUuid: String!): SharedResourceNode } +"""Soil ID algorithm queries.""" +type SoilId { + locationBasedSoilMatches(latitude: Float!, longitude: Float!): LocationBasedSoilMatches! + dataBasedSoilMatches(latitude: Float!, longitude: Float!, data: SoilIdInputData!): DataBasedSoilMatches! +} + +"""A ranked group of soil matches based solely on a coordinate pair.""" +type LocationBasedSoilMatches { + matches: [LocationBasedSoilMatch!]! +} + +"""A soil match based solely on a coordinate pair.""" +type LocationBasedSoilMatch { + dataSource: String! + distanceToNearestMapUnitM: Float! + soilInfo: SoilInfo! + match: SoilMatchInfo! +} + +"""Provides information about soil at a particular location.""" +type SoilInfo { + soilSeries: SoilSeries! + ecologicalSite: EcologicalSite + landCapabilityClass: LandCapabilityClass! + soilData: SoilIdSoilData! +} + +"""Information about a soil series.""" +type SoilSeries { + name: String! + taxonomySubgroup: String! + description: String! + fullDescriptionUrl: String! +} + +"""Information about an ecological site.""" +type EcologicalSite { + name: String! + id: String! + url: String! +} + +"""Caveat: may want to update these fields to an enum at some point.""" +type LandCapabilityClass { + capabilityClass: String! + subClass: String! +} + +"""Soil data associated with a soil match output by the soil algorithm.""" +type SoilIdSoilData { + slope: Float + depthDependentData: [SoilIdDepthDependentData!]! +} + +""" +Depth dependent soil data associated with a soil match output by the soil algorithm. +""" +type SoilIdDepthDependentData { + depthInterval: DepthInterval! + texture: SoilIdDepthDependentSoilDataTextureChoices + rockFragmentVolume: SoilIdDepthDependentSoilDataRockFragmentVolumeChoices + munsellColorString: String +} + +type DepthInterval { + start: Int! + end: Int! +} + +"""An enumeration.""" +enum SoilIdDepthDependentSoilDataTextureChoices { + """Sand""" + SAND + + """Loamy Sand""" + LOAMY_SAND + + """Sandy Loam""" + SANDY_LOAM + + """Silt Loam""" + SILT_LOAM + + """Silt""" + SILT + + """Loam""" + LOAM + + """Sandy Clay Loam""" + SANDY_CLAY_LOAM + + """Silty Clay Loam""" + SILTY_CLAY_LOAM + + """Clay Loam""" + CLAY_LOAM + + """Sandy Clay""" + SANDY_CLAY + + """Silty Clay""" + SILTY_CLAY + + """Clay""" + CLAY +} + +"""An enumeration.""" +enum SoilIdDepthDependentSoilDataRockFragmentVolumeChoices { + """0 — 1%""" + VOLUME_0_1 + + """1 — 15%""" + VOLUME_1_15 + + """15 — 35%""" + VOLUME_15_35 + + """35 — 60%""" + VOLUME_35_60 + + """> 60%""" + VOLUME_60 +} + +""" +The likelihood score and rank within the match group for a particular soil type. +""" +type SoilMatchInfo { + score: Float! + rank: Int! +} + +""" +A ranked group of soil matches based on a coordinate pair and soil data. +""" +type DataBasedSoilMatches { + matches: [DataBasedSoilMatch!]! +} + +"""A soil match based on a coordinate pair and soil data.""" +type DataBasedSoilMatch { + dataSource: String! + distanceToNearestMapUnitM: Float! + soilInfo: SoilInfo! + locationMatch: SoilMatchInfo! + dataMatch: SoilMatchInfo! + combinedMatch: SoilMatchInfo! +} + +"""Soil data provided to the soil ID algorithm.""" +input SoilIdInputData { + slope: Float + depthDependentData: [SoilIdInputDepthDependentData!]! +} + +"""Depth dependent data provided to the soil ID algorithm.""" +input SoilIdInputDepthDependentData { + depthInterval: DepthIntervalInput! + texture: SoilIdDepthDependentSoilDataTextureChoices = null + rockFragmentVolume: SoilIdDepthDependentSoilDataRockFragmentVolumeChoices = null + colorLAB: LABColorInput = null +} + +input DepthIntervalInput { + start: Int! + end: Int! +} + +input LABColorInput { + L: Float! + A: Float! + B: Float! +} + type GroupNode implements Node { slug: String! name: String! @@ -1119,11 +1297,6 @@ type SoilDataDepthIntervalNode { depthInterval: DepthInterval! } -type DepthInterval { - start: Int! - end: Int! -} - type DepthDependentSoilDataNode { texture: SoilIdDepthDependentSoilDataTextureChoices clayPercent: Int @@ -1151,63 +1324,6 @@ type DepthDependentSoilDataNode { depthInterval: DepthInterval! } -"""An enumeration.""" -enum SoilIdDepthDependentSoilDataTextureChoices { - """Sand""" - SAND - - """Loamy Sand""" - LOAMY_SAND - - """Sandy Loam""" - SANDY_LOAM - - """Silt Loam""" - SILT_LOAM - - """Silt""" - SILT - - """Loam""" - LOAM - - """Sandy Clay Loam""" - SANDY_CLAY_LOAM - - """Silty Clay Loam""" - SILTY_CLAY_LOAM - - """Clay Loam""" - CLAY_LOAM - - """Sandy Clay""" - SANDY_CLAY - - """Silty Clay""" - SILTY_CLAY - - """Clay""" - CLAY -} - -"""An enumeration.""" -enum SoilIdDepthDependentSoilDataRockFragmentVolumeChoices { - """0 — 1%""" - VOLUME_0_1 - - """1 — 15%""" - VOLUME_1_15 - - """15 — 35%""" - VOLUME_15_35 - - """35 — 60%""" - VOLUME_35_60 - - """> 60%""" - VOLUME_60 -} - """An enumeration.""" enum SoilIdDepthDependentSoilDataColorPhotoSoilConditionChoices { """Moist""" @@ -2193,11 +2309,6 @@ input DepthDependentSoilDataUpdateMutationInput { clientMutationId: String } -input DepthIntervalInput { - start: Int! - end: Int! -} - type SoilDataUpdateDepthIntervalMutationPayload { errors: GenericScalar soilData: SoilDataNode diff --git a/terraso_backend/apps/soil_id/graphql/soil_id.py b/terraso_backend/apps/soil_id/graphql/soil_id.py new file mode 100644 index 000000000..dc8282a9e --- /dev/null +++ b/terraso_backend/apps/soil_id/graphql/soil_id.py @@ -0,0 +1,277 @@ +# Copyright © 2024 Technology Matters +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see https://www.gnu.org/licenses/. + +import graphene + +from apps.soil_id.graphql.soil_data import ( + DepthDependentSoilDataNode, + DepthInterval, + DepthIntervalInput, +) + + +class EcologicalSite(graphene.ObjectType): + """Information about an ecological site.""" + + name = graphene.String(required=True) + id = graphene.String(required=True) + url = graphene.String(required=True) + + +class SoilSeries(graphene.ObjectType): + """Information about a soil series.""" + + name = graphene.String(required=True) + taxonomy_subgroup = graphene.String(required=True) + description = graphene.String(required=True) + full_description_url = graphene.String(required=True) + + +class LandCapabilityClass(graphene.ObjectType): + """Caveat: may want to update these fields to an enum at some point.""" + + capability_class = graphene.String(required=True) + sub_class = graphene.String(required=True) + + +class SoilIdDepthDependentData(graphene.ObjectType): + """Depth dependent soil data associated with a soil match output by the soil algorithm.""" + + depth_interval = graphene.Field(DepthInterval, required=True) + texture = DepthDependentSoilDataNode.texture_enum() + rock_fragment_volume = DepthDependentSoilDataNode.rock_fragment_volume_enum() + munsell_color_string = graphene.String() + + +class SoilIdSoilData(graphene.ObjectType): + """Soil data associated with a soil match output by the soil algorithm.""" + + slope = graphene.Float() + depth_dependent_data = graphene.List(graphene.NonNull(SoilIdDepthDependentData), required=True) + + +class SoilInfo(graphene.ObjectType): + """Provides information about soil at a particular location.""" + + soil_series = graphene.Field(SoilSeries, required=True) + ecological_site = graphene.Field(EcologicalSite, required=False) + land_capability_class = graphene.Field(LandCapabilityClass, required=True) + soil_data = graphene.Field(SoilIdSoilData, required=True) + + +class SoilMatchInfo(graphene.ObjectType): + """The likelihood score and rank within the match group for a particular soil type.""" + + score = graphene.Float(required=True) + rank = graphene.Int(required=True) + + +class SoilMatch(graphene.ObjectType): + """Base class for location/data based soil matches.""" + + class Meta: + abstract = True + + data_source = graphene.String(required=True) + distance_to_nearest_map_unit_m = graphene.Float(required=True) + soil_info = graphene.Field(SoilInfo, required=True) + + +class LocationBasedSoilMatch(SoilMatch): + """A soil match based solely on a coordinate pair.""" + + match = graphene.Field(SoilMatchInfo, required=True) + + +class LocationBasedSoilMatches(graphene.ObjectType): + """A ranked group of soil matches based solely on a coordinate pair.""" + + matches = graphene.List(graphene.NonNull(LocationBasedSoilMatch), required=True) + + +class DataBasedSoilMatch(SoilMatch): + """A soil match based on a coordinate pair and soil data.""" + + soil_info = graphene.Field(SoilInfo, required=True) + location_match = graphene.Field(SoilMatchInfo, required=True) + data_match = graphene.Field(SoilMatchInfo, required=True) + combined_match = graphene.Field(SoilMatchInfo, required=True) + + +class DataBasedSoilMatches(graphene.ObjectType): + """A ranked group of soil matches based on a coordinate pair and soil data.""" + + matches = graphene.List(graphene.NonNull(DataBasedSoilMatch), required=True) + + +class LABColorInput(graphene.InputObjectType): + L = graphene.Float(required=True) + A = graphene.Float(required=True) + B = graphene.Float(required=True) + + +class SoilIdInputDepthDependentData(graphene.InputObjectType): + """Depth dependent data provided to the soil ID algorithm.""" + + depth_interval = graphene.Field(DepthIntervalInput, required=True) + texture = graphene.Field(DepthDependentSoilDataNode.texture_enum()) + rock_fragment_volume = graphene.Field(DepthDependentSoilDataNode.rock_fragment_volume_enum()) + color_LAB = graphene.Field(LABColorInput, name="colorLAB") + + +class SoilIdInputData(graphene.InputObjectType): + """Soil data provided to the soil ID algorithm.""" + + slope = graphene.Float() + depth_dependent_data = graphene.List( + graphene.NonNull(SoilIdInputDepthDependentData), required=True + ) + + +sample_soil_infos = [ + SoilInfo( + soil_series=SoilSeries( + name="Yemassee", + taxonomy_subgroup="Aeric Endoaquults", + description="The Yemassee series consists of very deep, somewhat poorly drained, moderately permeable, loamy soils that formed in marine sediments. These soils are on terraces and broad flats of the lower Coastal Plain. Slopes range from 0 to 2 percent.", # noqa: E501 <- flake8 ignore line length + full_description_url="https://casoilresource.lawr.ucdavis.edu/sde/?series=yemassee", # noqa: E501 <- flake8 ignore line length + ), + ecological_site=EcologicalSite( + name="Loamy Rise, Moderately Wet", + id="R153AY001GA", + url="https://edit.jornada.nmsu.edu/catalogs/esd/153A/R153AY001GA", + ), + land_capability_class=LandCapabilityClass(capability_class="6", sub_class="w"), + soil_data=SoilIdSoilData( + slope=0.5, + depth_dependent_data=[ + SoilIdDepthDependentData( + depth_interval=DepthInterval(start=0, end=10), + texture="CLAY_LOAM", + rock_fragment_volume="VOLUME_1_15", + munsell_color_string="10R 5/4", + ), + SoilIdDepthDependentData( + depth_interval=DepthInterval(start=10, end=15), + texture="SILT", + rock_fragment_volume="VOLUME_15_35", + munsell_color_string="10YR 2/6", + ), + ], + ), + ), + SoilInfo( + soil_series=SoilSeries( + name="Randall", + taxonomy_subgroup="Ustic Epiaquerts", + description="The Randall series consists of very deep, poorly drained, very slowly permeable soils that formed in clayey lacustrine sediments derived from the Blackwater Draw Formation of Pleistocene age. These nearly level soils are on the floor of playa basins 3 to 15 m (10 to 50 ft) below the surrounding plain and range in size from 10 to more than 150 acres. Slope ranges from 0 to 1 percent. Mean annual precipitation is 483 mm (19 in), and mean annual temperature is 15 degrees C (59 degrees F).", # noqa: E501 <- flake8 ignore line length + full_description_url="https://casoilresource.lawr.ucdavis.edu/sde/?series=randall", # noqa: E501 <- flake8 ignore line length + ), + land_capability_class=LandCapabilityClass(capability_class="4", sub_class="s-a"), + soil_data=SoilIdSoilData( + slope=0.5, + depth_dependent_data=[ + SoilIdDepthDependentData( + depth_interval=DepthInterval(start=0, end=10), + texture="CLAY_LOAM", + rock_fragment_volume="VOLUME_1_15", + munsell_color_string="10R 5/4", + ), + SoilIdDepthDependentData( + depth_interval=DepthInterval(start=10, end=15), + texture="SILT", + rock_fragment_volume="VOLUME_15_35", + munsell_color_string="N 4/", + ), + ], + ), + ), +] + + +# to be replaced by actual algorithm output +def resolve_location_based_soil_matches(_parent, _info, latitude: float, longitude: float): + return LocationBasedSoilMatches( + matches=[ + LocationBasedSoilMatch( + data_source="SSURGO", + distance_to_nearest_map_unit_m=0.0, + match=SoilMatchInfo(score=1.0, rank=0), + soil_info=sample_soil_infos[0], + ), + LocationBasedSoilMatch( + data_source="STATSGO", + distance_to_nearest_map_unit_m=50.0, + match=SoilMatchInfo(score=0.5, rank=1), + soil_info=sample_soil_infos[1], + ), + ] + ) + + +# 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], + ), + ] + ) + + +class SoilId(graphene.ObjectType): + """Soil ID algorithm queries.""" + + location_based_soil_matches = graphene.Field( + LocationBasedSoilMatches, + latitude=graphene.Float(required=True), + longitude=graphene.Float(required=True), + resolver=resolve_location_based_soil_matches, + required=True, + ) + + data_based_soil_matches = graphene.Field( + DataBasedSoilMatches, + latitude=graphene.Float(required=True), + longitude=graphene.Float(required=True), + data=graphene.Argument(SoilIdInputData, required=True), + resolver=resolve_data_based_soil_matches, + required=True, + ) + + +def resolve_soil_id(parent, info): + return SoilId() + + +soil_id = graphene.Field( + SoilId, required=True, resolver=resolve_soil_id, description="Soil ID algorithm Queries" +) diff --git a/terraso_backend/tests/graphql/test_soil_id.py b/terraso_backend/tests/graphql/test_soil_id.py new file mode 100644 index 000000000..c820b4905 --- /dev/null +++ b/terraso_backend/tests/graphql/test_soil_id.py @@ -0,0 +1,186 @@ +# Copyright © 2024 Technology Matters +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see https://www.gnu.org/licenses/. + + +import pytest +import structlog +from graphene_django.utils.testing import graphql_query + +pytestmark = pytest.mark.django_db + +logger = structlog.get_logger(__name__) + +SOIL_MATCH_FRAGMENTS = """ + fragment soilMatch on SoilMatchInfo { + score + rank + } + fragment soilInfo on SoilInfo { + soilSeries { + name + taxonomySubgroup + description + fullDescriptionUrl + } + ecologicalSite { + name + id + url + } + landCapabilityClass { + capabilityClass + subClass + } + soilData { + slope + depthDependentData { + depthInterval { + start + end + } + texture + rockFragmentVolume + munsellColorString + } + } + } +""" + +LOCATION_BASED_MATCHES_QUERY = ( + """ + query locationBasedSoilMatches($latitude: Float!, $longitude: Float!) { + soilId { + locationBasedSoilMatches(latitude: $latitude, longitude: $longitude) { + ...locationBasedSoilMatches + } + } + } + fragment locationBasedSoilMatches on LocationBasedSoilMatches { + matches { + dataSource + distanceToNearestMapUnitM + match { + ...soilMatch + } + soilInfo { + ...soilInfo + } + } + } +""" + + SOIL_MATCH_FRAGMENTS +) + + +def test_location_based_soil_matches_endpoint(client): + response = graphql_query( + LOCATION_BASED_MATCHES_QUERY, variables={"latitude": 0.0, "longitude": 0.0}, client=client + ) + + assert response.json()["data"] is not None + + payload = response.json()["data"]["soilId"]["locationBasedSoilMatches"] + + assert len(payload["matches"]) > 0 + + for match in payload["matches"]: + assert isinstance(match["dataSource"], str) + assert isinstance(match["distanceToNearestMapUnitM"], float) + + assert match["match"]["score"] >= 0 and match["match"]["score"] <= 1 + assert match["match"]["rank"] >= 0 + + info = match["soilInfo"] + + assert info["soilSeries"] is not None + assert info["landCapabilityClass"] is not None + assert info["soilData"] is not None + assert len(info["soilData"]["depthDependentData"]) > 0 + + +DATA_BASED_MATCHES_QUERY = ( + """ + query dataBasedSoilMatches($latitude: Float!, $longitude: Float!, $data: SoilIdInputData!) { + soilId { + dataBasedSoilMatches(latitude: $latitude, longitude: $longitude, data: $data) { + ...dataBasedSoilMatches + } + } + } + fragment dataBasedSoilMatches on DataBasedSoilMatches { + matches { + dataSource + distanceToNearestMapUnitM + locationMatch { + ...soilMatch + } + dataMatch { + ...soilMatch + } + combinedMatch { + ...soilMatch + } + soilInfo { + ...soilInfo + } + } + } +""" + + SOIL_MATCH_FRAGMENTS +) + + +def test_data_based_soil_matches_endpoint(client): + response = graphql_query( + DATA_BASED_MATCHES_QUERY, + variables={ + "latitude": 0.0, + "longitude": 0.0, + "data": { + "slope": 0.5, + "depthDependentData": [ + { + "depthInterval": {"start": 0, "end": 10}, + "texture": "CLAY", + "rockFragmentVolume": "VOLUME_0_1", + "colorLAB": {"L": 20, "A": 30, "B": 40}, + } + ], + }, + }, + client=client, + ) + + assert response.json()["data"] is not None + + payload = response.json()["data"]["soilId"]["dataBasedSoilMatches"] + + assert len(payload["matches"]) > 0 + + for match in payload["matches"]: + assert isinstance(match["dataSource"], str) + assert isinstance(match["distanceToNearestMapUnitM"], float) + + match_kinds = ["locationMatch", "dataMatch", "combinedMatch"] + for kind in match_kinds: + assert match[kind]["score"] >= 0 and match[kind]["score"] <= 1 + assert match[kind]["rank"] >= 0 + + info = match["soilInfo"] + + assert info["soilSeries"] is not None + assert info["landCapabilityClass"] is not None + assert info["soilData"] is not None + assert len(info["soilData"]["depthDependentData"]) > 0