diff --git a/loko/assets/models/nature.py b/loko/assets/models/nature.py index 668d11c..f37440c 100644 --- a/loko/assets/models/nature.py +++ b/loko/assets/models/nature.py @@ -281,6 +281,11 @@ class NatureTree(AbstractGeoAsset): verbose_name = _("Arbre") verbose_name_plural = _("Arbres") + def save(self, *args, **kwargs): + from assets.utils.tree_ecoservices import compute_tree_ecoservices + compute_tree_ecoservices(self, overwrite_existing=False) + super().save(*args, **kwargs) + def get_absolute_url(self): return reverse('assets:nature_assets_detail', kwargs={'asset_model': 'naturetree', 'asset_id':self.id}) diff --git a/loko/assets/tests.py b/loko/assets/tests.py index 2520abe..265354f 100644 --- a/loko/assets/tests.py +++ b/loko/assets/tests.py @@ -5708,3 +5708,52 @@ class NatureLocationDetailViewTest(TestCase): self.assertContains(response, 'id="asset-map"') + + +class NatureTreeEcoServicesTest(TestCase): + def test_tree_ecoservices_automatic_calculation_on_save(self): + from assets.models import NatureTree + tree = NatureTree.objects.create( + code="TR-ECO-TEST-1", + name_fr="Chêne test", + scientific_name="Quercus robur", + height=10.0, + circumference=70.0, + crown_diameter=5.0, + vitality="normal", + ) + self.assertIsNotNone(tree.amenity_value) + self.assertGreater(tree.amenity_value, 500) + self.assertIsNotNone(tree.carbon_stock) + self.assertGreater(tree.carbon_stock, 5.0) + self.assertIsNotNone(tree.cooling_energy_indicator) + self.assertEqual(tree.cooling_energy_indicator, 7408.93) + self.assertEqual(tree.cooling_indicator, 12.75) + self.assertEqual(tree.biodiversity_index, 0.92) + + def test_tree_missing_dimensions_deduction(self): + from assets.models import NatureTree + tree = NatureTree.objects.create( + code="TR-ECO-TEST-2", + name_fr="Platane sans circonférence", + scientific_name="Platanus x acerifolia", + height=20.0, + ) + self.assertEqual(tree.circumference, 150.0) + self.assertEqual(tree.crown_diameter, 9.0) + self.assertIsNotNone(tree.amenity_value) + self.assertIsNotNone(tree.carbon_stock) + self.assertEqual(tree.biodiversity_index, 0.85) + + def test_dead_tree_has_zero_amenity_and_cooling(self): + from assets.models import NatureTree + tree = NatureTree.objects.create( + code="TR-ECO-DEAD", + name_fr="Arbre mort", + height=8.0, + circumference=60.0, + is_dead=True, + ) + self.assertEqual(tree.amenity_value, 0.0) + self.assertEqual(tree.cooling_energy_indicator, 0.0) + self.assertEqual(tree.cooling_indicator, 0.0) diff --git a/loko/assets/utils/tree_ecoservices.py b/loko/assets/utils/tree_ecoservices.py new file mode 100644 index 0000000..be84785 --- /dev/null +++ b/loko/assets/utils/tree_ecoservices.py @@ -0,0 +1,190 @@ +import math +from typing import Optional + + +# Coefficients allométriques carbone par genre botanique (t CO2 / (cm_circ * m_hauteur)) +# Dérivés des tables de biomasse et densités ligneuses (i-Tree / Chave / Grality) +GENUS_CARBON_K = { + "aesculus": 0.02471, # Marronnier + "carpinus": 0.02370, # Charme + "quercus": 0.01952, # Chêne + "betula": 0.01952, # Bouleau + "fagus": 0.01882, # Hêtre + "prunus": 0.01813, # Cerisier / Prunier + "fraxinus": 0.01743, # Frêne + "acer": 0.01673, # Érable + "platanus": 0.01673, # Platane + "castanea": 0.01900, # Châtaignier + "robinia": 0.01850, # Robinier + "ulmus": 0.01750, # Orme + "cedrus": 0.01600, # Cèdre + "salix": 0.01550, # Saule + "populus": 0.01500, # Peuplier + "pinus": 0.01450, # Pin + "tilia": 0.01366, # Tilleul + "malus": 0.01800, # Pommier + "pyrus": 0.01800, # Poirier +} + +# Indices écologiques de biodiversité par genre / essence +GENUS_BIODIVERSITY = { + "quercus": 0.92, # Chêne (très forte valeur d'accueil) + "fagus": 0.88, # Hêtre + "platanus": 0.85, # Platane + "fraxinus": 0.78, # Frêne + "tilia": 0.78, # Tilleul + "carpinus": 0.76, # Charme + "salix": 0.75, # Saule + "acer": 0.68, # Érable + "aesculus": 0.62, # Marronnier + "pinus": 0.62, # Pin + "populus": 0.60, # Peuplier + "ulmus": 0.60, # Orme + "malus": 0.53, # Pommier + "betula": 0.47, # Bouleau + "prunus": 0.44, # Cerisier / Prunus ornemental +} + +# Constante d'énergie de refroidissement par an (kWh / m^2 de canopée projetée) +# Dérivée de l'évapotranspiration urbaine Grality : 296.357 * D^2 +COOLING_ENERGY_PER_D2 = 296.357 + +# Valeur de base au cm² de section de tronc (Barème de l'Arbre / Plante & Cité) +BASE_AMENITY_VALUE_PER_CM2 = 3.20 + + +def get_genus_carbon_k(botanical_str: Optional[str]) -> float: + """Retourne le coefficient allométrique de carbone pour l'essence ou genre spécifié.""" + if not botanical_str: + return 0.0180 + b_lower = botanical_str.lower() + for genus, k in GENUS_CARBON_K.items(): + if genus in b_lower: + return k + return 0.0180 + + +def get_genus_biodiversity_index(botanical_str: Optional[str]) -> float: + """Retourne l'indice de biodiversité pour l'essence ou genre spécifié.""" + if not botanical_str: + return 0.60 + b_lower = botanical_str.lower() + for genus, b in GENUS_BIODIVERSITY.items(): + if genus in b_lower: + return b + return 0.60 + + +def compute_tree_ecoservices(tree, overwrite_existing: bool = False) -> bool: + """ + Calcule et met à jour automatiquement les indicateurs écosystémiques et patrimoniaux + d'un arbre (NatureTree) : + 1. Dimensions déduites si manquantes (circonférence et diamètre de houppier). + 2. Valeur d'aménité (€) selon le Barème de l'Arbre (section du tronc et coefficients). + 3. Stock de carbone (t CO2) selon l'allométrie dendrométrique et l'essence. + 4. Énergie de refroidissement (kWh) et Indice de fraîcheur selon la canopée. + 5. Indice de biodiversité selon l'essence. + + Si overwrite_existing=False (par défaut), ne remplace pas les valeurs déjà renseignées + (ex: valeurs officielles Grality déjà enregistrées). + Retourne True si au moins un champ a été calculé/mis à jour. + """ + changed = False + + # 1. Estimation / déduction des dimensions manquantes à partir de la hauteur ou circonférence + h = getattr(tree, 'height', None) + c = getattr(tree, 'circumference', None) + crown_d = getattr(tree, 'crown_diameter', None) + + if (not c or c <= 0) and h and h > 0: + c = round(h * 7.5, 1) + tree.circumference = c + changed = True + + if (not crown_d or crown_d <= 0): + if h and h > 0: + crown_d = round(max(2.0, min(18.0, h * 0.45)), 1) + tree.crown_diameter = crown_d + changed = True + elif c and c > 0: + estimated_h = c / 7.5 + crown_d = round(max(2.0, min(18.0, estimated_h * 0.45)), 1) + tree.crown_diameter = crown_d + changed = True + + if (not h or h <= 0) and c and c > 0: + h = round(c / 7.5, 1) + tree.height = h + changed = True + + # 2. Coefficients de situation et plantation par défaut si absents + sit = getattr(tree, 'situation_coefficient', None) + if sit is None or overwrite_existing: + ctx = getattr(tree, 'implantation_context', 'street') + sit = 5.0 if ctx in ('park', 'square', 'isolated') else 4.0 + tree.situation_coefficient = sit + changed = True + + plant = getattr(tree, 'plantation_coefficient', None) + if plant is None or overwrite_existing: + plant = 5.0 + tree.plantation_coefficient = plant + changed = True + + # Statut sanitaire / vitalité + is_dead = bool(getattr(tree, 'is_dead', False)) + vitality = getattr(tree, 'vitality', 'normal') or 'normal' + + if is_dead or vitality == 'dead': + sa_coeff = 0.0 + elif vitality == 'declining': + sa_coeff = 0.3 + elif vitality == 'weak': + sa_coeff = 0.6 + else: + sa_coeff = 1.0 + + # 3. Valeur d'aménité (€) — Barème de l'Arbre + if getattr(tree, 'amenity_value', None) is None or overwrite_existing: + if sa_coeff == 0.0: + tree.amenity_value = 0.0 + changed = True + elif c and c > 0: + section_cm2 = (c ** 2) / (4.0 * math.pi) + tree.amenity_value = round( + section_cm2 * BASE_AMENITY_VALUE_PER_CM2 * (sit / 5.0) * (plant / 5.0) * sa_coeff, + 2 + ) + changed = True + + # 4. Énergie de refroidissement (kWh) et Indice de fraîcheur + if getattr(tree, 'cooling_energy_indicator', None) is None or overwrite_existing: + if is_dead or vitality == 'dead': + tree.cooling_energy_indicator = 0.0 + changed = True + elif crown_d and crown_d > 0: + tree.cooling_energy_indicator = round(COOLING_ENERGY_PER_D2 * (crown_d ** 2), 2) + changed = True + + if getattr(tree, 'cooling_indicator', None) is None or overwrite_existing: + if is_dead or vitality == 'dead': + tree.cooling_indicator = 0.0 + changed = True + elif tree.cooling_energy_indicator is not None and tree.cooling_energy_indicator > 0: + tree.cooling_indicator = round(math.sqrt(tree.cooling_energy_indicator) / 6.75, 2) + changed = True + + # 5. Stock de carbone (t CO2) + botanical = getattr(tree, 'scientific_name', '') or getattr(tree, 'genus', '') or getattr(tree, 'name_fr', '') + if getattr(tree, 'carbon_stock', None) is None or overwrite_existing: + if c and h and c > 0 and h > 0: + k = get_genus_carbon_k(botanical) + tree.carbon_stock = round(k * c * h, 3) + changed = True + + # 6. Indice de biodiversité + if getattr(tree, 'biodiversity_index', None) is None or overwrite_existing: + tree.biodiversity_index = get_genus_biodiversity_index(botanical) + changed = True + + return changed