feat(assets): add automatic calculation of tree ecological and patrimonial eco-services
This commit is contained in:
parent
e51f698030
commit
b953b1f984
3 changed files with 244 additions and 0 deletions
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
190
loko/assets/utils/tree_ecoservices.py
Normal file
190
loko/assets/utils/tree_ecoservices.py
Normal 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
|
||||
Loading…
Reference in a new issue