feat(assets): add automatic calculation of tree ecological and patrimonial eco-services

This commit is contained in:
kdeterme 2026-10-10 08:34:50 +02:00
parent e51f698030
commit b953b1f984
3 changed files with 244 additions and 0 deletions

View file

@ -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})

View file

@ -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)

View file

@ -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