diff --git a/tests/test_context_layer_tool.py b/tests/test_context_layer_tool.py index 067c76b..d0336f3 100644 --- a/tests/test_context_layer_tool.py +++ b/tests/test_context_layer_tool.py @@ -7,6 +7,7 @@ def test_context_layer_tool_cereal(): ) assert result == "ESA/WorldCereal/2021/MODELS/v100" + def test_context_layer_tool_null(): result = context_layer_tool.invoke( input={"question": "Provide disturbances for Aveiro Portugal"} diff --git a/tests/test_dist_agent.py b/tests/test_dist_agent.py index f219155..6607abf 100644 --- a/tests/test_dist_agent.py +++ b/tests/test_dist_agent.py @@ -1,11 +1,10 @@ - from zeno.agents.distalert.agent import graph from zeno.agents.maingraph.utils.state import GraphState def test_distalert_agent(): initial_state = GraphState( - question="Provide data about disturbance alerts in Aveiro summarized by landcover" + question="Provide data about disturbance alerts in Aveiro summarized by natural lands in 2023" ) for namespace, chunk in graph.stream( initial_state, stream_mode="updates", subgraphs=True @@ -17,4 +16,6 @@ def test_distalert_agent(): if not messages: continue msg = messages[0] + if msg.name == "dist-alerts-tool": + assert "Natural forests" in msg.content print(msg) diff --git a/tests/test_dist_alerts.py b/tests/test_dist_alerts.py index 1e12deb..9577cc1 100644 --- a/tests/test_dist_alerts.py +++ b/tests/test_dist_alerts.py @@ -1,3 +1,5 @@ +import datetime + from zeno.tools.contextlayer.layers import layer_choices from zeno.tools.distalert.dist_alerts_tool import dist_alerts_tool @@ -6,17 +8,30 @@ def test_dist_alert_tool(): features = ["2323"] result = dist_alerts_tool.invoke( - input={"features": features, "landcover": layer_choices[1]["dataset"], "threshold": 8} + input={ + "features": features, + "landcover": layer_choices[1]["dataset"], + "threshold": 8, + "min_date": datetime.date(2021, 8, 12), + "max_date": datetime.date(2024, 8, 12), + } ) assert len(result) == 1 assert "AGO.1.3.4_1" in result + def test_dist_alert_tool_no_landcover(): features = ["2323"] result = dist_alerts_tool.invoke( - input={"features": features, "landcover": None, "threshold": 5} + input={ + "features": features, + "landcover": None, + "threshold": 5, + "min_date": datetime.date(2021, 8, 12), + "max_date": datetime.date(2024, 8, 12), + } ) assert len(result) == 1 diff --git a/zeno/tools/contextlayer/layers.py b/zeno/tools/contextlayer/layers.py index 25dbe89..9e8edd9 100644 --- a/zeno/tools/contextlayer/layers.py +++ b/zeno/tools/contextlayer/layers.py @@ -2,7 +2,7 @@ DatasetNames = Literal[ "", - "WRI/SBTN/naturalLands/v1", + "WRI/SBTN/naturalLands/v1/2020", "ESA/WorldCover/v200", "GOOGLE/DYNAMICWORLD/V1", "JAXA/ALOS/PALSAR/YEARLY/FNF4", @@ -14,12 +14,34 @@ layer_choices = [ { "name": "SBTN Natural Lands Map v1", - "dataset": "WRI/SBTN/naturalLands/v1", + "dataset": "WRI/SBTN/naturalLands/v1/2020", "description": "The SBTN Natural Lands Map v1 is a 2020 baseline map of natural and non-natural land covers intended for use by companies setting science-based targets for nature, specifically the SBTN Land target #1: no conversion of natural ecosystems. 'Natural' and 'non-natural' definitions were adapted from the Accountability Framework initiative's definition of a natural ecosystem as 'one that substantially resembles - in terms of species composition, structure, and ecological function - what would be found in a given area in the absence of major human impacts' and can include managed ecosystems as well as degraded ecosystems that are expected to regenerate either naturally or through management (AFi 2024). The SBTN Natural Lands Map operationalizes this definition by using proxies based on available data that align with AFi guidance to the extent possible. This map was made by compiling existing global and regional data.You can find the full technical note explaining the methodology linked on the Natural Lands GitHub. This work was a collaboration between Land & Carbon Lab at the World Resources Institute, World Wildlife Fund US, Systemiq, and SBTN.", "resolution": 30, "year": 2020, "band": "classification", "type": "Image", + "class_table": { + 2: {"color": "#246E24", "name": "Natural forests"}, + 3: {"color": "#B9B91E", "name": "Natural short vegetation"}, + 4: {"color": "#6BAED6", "name": "Natural water"}, + 5: {"color": "#06A285", "name": "Mangroves"}, + 6: {"color": "#FEFECC", "name": "Bare"}, + 7: {"color": "#ACD1E8", "name": "Snow"}, + 8: {"color": "#589558", "name": "Wet natural forests"}, + 9: {"color": "#093D09", "name": "Natural peat forests"}, + 10: {"color": "#DBDB7B", "name": "Wet natural short vegetation"}, + 11: {"color": "#99991A", "name": "Natural peat short vegetation"}, + 12: {"color": "#D3D3D3", "name": "Crop"}, + 13: {"color": "#D3D3D3", "name": "Built"}, + 14: {"color": "#D3D3D3", "name": "Non-natural tree cover"}, + 15: {"color": "#D3D3D3", "name": "Non-natural short vegetation"}, + 16: {"color": "#D3D3D3", "name": "Non-natural water"}, + 17: {"color": "#D3D3D3", "name": "Wet non-natural tree cover"}, + 18: {"color": "#D3D3D3", "name": "Non-natural peat tree cover"}, + 19: {"color": "#D3D3D3", "name": "Wet non-natural short vegetation"}, + 20: {"color": "#D3D3D3", "name": "Non-natural peat short vegetation"}, + 21: {"color": "#D3D3D3", "name": "Non-natural bare"}, + } }, { "name": "ESA WorldCover", diff --git a/zeno/tools/distalert/dist_alerts_tool.py b/zeno/tools/distalert/dist_alerts_tool.py index 9f1900d..f3d5f40 100644 --- a/zeno/tools/distalert/dist_alerts_tool.py +++ b/zeno/tools/distalert/dist_alerts_tool.py @@ -1,3 +1,4 @@ +import datetime from typing import List, Literal, Optional, Union import ee @@ -18,6 +19,9 @@ gadm = fiona.open("data/gadm_410_small.gpkg") +DIST_ALERT_REF_DATE = datetime.date(2020, 12, 31) +DIST_ALERT_SCALE = 30 + class DistAlertsInput(BaseModel): """Input schema for dist tool""" @@ -31,7 +35,14 @@ class DistAlertsInput(BaseModel): threshold: Optional[Literal[1, 2, 3, 4, 5, 6, 7, 8]] = Field( default=5, description="Threshold for disturbance alert scale" ) - + min_date: Optional[datetime.date] = Field( + default=None, + description="Cutoff date for alerts. Alerts before that date will be excluded.", + ) + max_date: Optional[datetime.date] = Field( + default=None, + description="Cutoff date for alerts. Alerts after that date will be excluded.", + ) def print_meta( layer: Union[ee.image.Image, ee.imagecollection.ImageCollection] @@ -72,6 +83,8 @@ def dist_alerts_tool( features: List[str], landcover: Optional[str] = None, threshold: Optional[Literal[1, 2, 3, 4, 5, 6, 7, 8]] = 5, + min_date: Optional[datetime.date] = None, + max_date: Optional[datetime.date] = None, ) -> dict: """ Dist alerts tool @@ -88,56 +101,102 @@ def dist_alerts_tool( [ee.Feature(gadm[int(id)].__geo_interface__) for id in features] ) - combo = distalerts.gte(threshold) + today = datetime.date.today() + date_mask = None + if min_date and min_date > DIST_ALERT_REF_DATE and min_date < today: + days_passed = (today - min_date).days + days_since_start = (today - DIST_ALERT_REF_DATE).days + cutoff = days_since_start - days_passed + date_mask = ( + ee.ImageCollection("projects/glad/HLSDIST/current/VEG-DIST-DATE") + .mosaic() + .gte(cutoff) + .selfMask() + ) + + if max_date and max_date > DIST_ALERT_REF_DATE and max_date < today: + days_passed = (today - max_date).days + days_since_start = (today - DIST_ALERT_REF_DATE).days + cutoff = days_since_start - days_passed + date_mask_max = ( + ee.ImageCollection("projects/glad/HLSDIST/current/VEG-DIST-DATE") + .mosaic() + .lte(cutoff) + .selfMask() + ) + if date_mask: + date_mask = date_mask.And(date_mask_max) + else: + date_mask = date_mask_max if landcover: choice = [dat for dat in layer_choices if dat["dataset"] == landcover][0] if choice["type"] == "ImageCollection": - landcover_layer = ee.ImageCollection(landcover) # .mosaic() + landcover_layer = ee.ImageCollection(landcover) else: landcover_layer = ee.Image(landcover) - class_table = get_class_table(choice["band"], landcover_layer) + if "class_table" in choice: + class_table = choice["class_table"] + else: + class_table = get_class_table(choice["band"], landcover_layer) if choice["type"] == "ImageCollection": landcover_layer = landcover_layer.mosaic() landcover_layer = landcover_layer.select(choice["band"]) - combo = combo.addBands(landcover_layer) - zone_stats = combo.reduceRegions( + zone_stats_img = ( + distalerts.pixelArea() + .divide(10000) + .addBands(landcover_layer) + .updateMask(distalerts.gte(threshold)) + ) + if date_mask: + zone_stats_img = zone_stats_img.updateMask( + zone_stats_img.selfMask().And(date_mask) + ) + + zone_stats = zone_stats_img.reduceRegions( collection=gee_features, - reducer=ee.Reducer.count().group(groupField=1, groupName=choice["band"]), + reducer=ee.Reducer.sum().group(groupField=1, groupName=choice["band"]), scale=choice["resolution"], ).getInfo() + zone_stats_result = {} for feat in zone_stats["features"]: zone_stats_result[feat["properties"]["gadmid"]] = { - class_table[dat[choice["band"]]]["name"]: dat["count"] + class_table[dat[choice["band"]]]["name"]: dat["sum"] for dat in feat["properties"]["groups"] } - vectorize = landcover_layer.updateMask(distalerts.gte(threshold).selfMask()) + vectorize = landcover_layer.updateMask(distalerts.gte(threshold)) else: - zone_stats = ( - distalerts.gte(threshold) - .selfMask() - .reduceRegions( - collection=gee_features, - reducer=ee.Reducer.count(), - scale=30, - ) - .getInfo() + zone_stats_img = ( + distalerts.pixelArea().divide(10000).updateMask(distalerts.gte(threshold)) ) + if date_mask: + zone_stats_img = zone_stats_img.updateMask( + zone_stats_img.selfMask().And(date_mask) + ) + + zone_stats = zone_stats_img.reduceRegions( + collection=gee_features, + reducer=ee.Reducer.sum(), + scale=DIST_ALERT_SCALE, + ).getInfo() + zone_stats_result = { - feat["properties"]["gadmid"]: {"disturbances": feat["properties"]["count"]} + feat["properties"]["gadmid"]: {"disturbances": feat["properties"]["sum"]} for feat in zone_stats["features"] } - vectorize = distalerts.gte(threshold).selfMask() + vectorize = ( + distalerts.gte(threshold).updateMask(distalerts.gte(threshold)).selfMask() + ) # Vectorize the masked classification vectors = vectorize.reduceToVectors( geometryType="polygon", - scale=30, + scale=DIST_ALERT_SCALE, maxPixels=1e8, geometry=gee_features, eightConnected=True,