feat: Phase 3 complete — push alerts, feedback scoring, S3 fallback, SPA routing, mobile CSS

- ImpactScoreService: implement getFeedbackBonus() (ST_Distance query on UserFeedback within 20km/6h)
- ImpactScoreService: trigger PushNotificationService when score >= 70
- PushNotificationService: send VAPID WebPush to spot subscribers, clean expired subs
- PushController: GET vapid-public-key, POST/DELETE subscribe with rate limiting
- SentinelHubClient: add optional $collection param, add Sentinel-3 OLCI FAI evalscript (MCI)
- IngestionService: add $collection param + HighCloudCoverageException for fallback logic
- IngestionService: add ingestWithFallback() — tries S2, falls back to S3 on high cloud
- IngestSentinelCommand: --source=auto (default) triggers ingestWithFallback
- FeedbackController: rate limiting via apiFeedbackLimiter
- Migration: push_subscription table
- rate_limiter.yaml: api_read(120/min), api_feedback(10/min), api_push(5/min)
- sw.js: service worker handling push events + notificationclick
- usePushSubscription hook: subscribe/unsubscribe lifecycle with VAPID
- SpotPanel: PushButton component integrated
- SpotPanel.css + TimelineSlider.css: mobile responsive (bottom-sheet on small screens)
- Caddyfile: SPA served at / with try_files fallback, sw.js served from root scope
- vite.config.js: build outDir → backend/public (not /spa)
- deploy/post-receive.sh: full deploy script (composer, npm build, migrations, cache, docker up)
- docs/roadmap.md: all Phase 3 + transversal items marked done

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gwadaking
2026-04-01 03:43:56 -04:00
parent 94bb6f5e8c
commit 2cef3725e9
24 changed files with 780 additions and 42 deletions

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Command;
use Minishlink\WebPush\VAPID;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:generate-vapid-keys',
description: 'Génère les clés VAPID pour les notifications push',
)]
class GenerateVapidKeysCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$keys = VAPID::createVapidKeys();
$io->title('Clés VAPID générées');
$io->text('Ajoutez ces lignes dans votre .env.local (ou secrets Docker) :');
$io->newLine();
$io->writeln('VAPID_PUBLIC_KEY=' . $keys['publicKey']);
$io->writeln('VAPID_PRIVATE_KEY=' . $keys['privateKey']);
$io->newLine();
$io->warning('Ne committez JAMAIS la clé privée. Utilisez les secrets Docker ou .env.local (non commité).');
return Command::SUCCESS;
}
}

View File

@@ -38,7 +38,7 @@ class IngestSentinelCommand extends Command
->addOption('zone', 'z', InputOption::VALUE_OPTIONAL,
'Zone spécifique. Défaut : toutes les zones.', null)
->addOption('source', 's', InputOption::VALUE_OPTIONAL,
'Source satellite (Sentinel-2 ou Sentinel-3)', 'Sentinel-2');
'Source satellite : Sentinel-2, Sentinel-3, ou auto (S2 avec fallback S3)', 'auto');
}
protected function execute(InputInterface $input, OutputInterface $output): int
@@ -71,7 +71,15 @@ class IngestSentinelCommand extends Command
foreach ($zones as $name => $bbox) {
$io->section("Zone : {$name}");
try {
$this->ingestionService->ingest($bbox, $date, $source);
if ($source === 'auto') {
$this->ingestionService->ingestWithFallback($bbox, $date);
} else {
$collection = match (strtolower($source)) {
'sentinel-3' => 'sentinel-3-olci',
default => 'sentinel-2-l2a',
};
$this->ingestionService->ingest($bbox, $date, $source, $collection);
}
$io->success("OK — {$name}");
} catch (\Throwable $e) {
$io->error("ÉCHEC — {$name} : " . $e->getMessage());

View File

@@ -8,12 +8,16 @@ use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/feedback', name: 'api_feedback_')]
class FeedbackController extends AbstractController
{
public function __construct(private EntityManagerInterface $em) {}
public function __construct(
private EntityManagerInterface $em,
private RateLimiterFactory $apiFeedbackLimiter,
) {}
/**
* POST /api/feedback
@@ -30,6 +34,11 @@ class FeedbackController extends AbstractController
#[Route('', name: 'create', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
$limiter = $this->apiFeedbackLimiter->create($request->getClientIp() ?? 'unknown');
if (!$limiter->consume()->isAccepted()) {
return $this->json(['error' => 'Too many requests'], 429);
}
$data = json_decode($request->getContent(), true);
if (!is_array($data)) {

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Controller;
use App\Entity\CoastalPoint;
use App\Entity\PushSubscription;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/push', name: 'api_push_')]
class PushController extends AbstractController
{
public function __construct(
private EntityManagerInterface $em,
private RateLimiterFactory $apiPushLimiter,
#[Autowire('%env(VAPID_PUBLIC_KEY)%')] private string $vapidPublicKey,
) {}
/**
* GET /api/push/vapid-public-key
* Retourne la clé publique VAPID pour l'abonnement push côté client.
*/
#[Route('/vapid-public-key', name: 'vapid_key', methods: ['GET'])]
public function vapidKey(): JsonResponse
{
return $this->json(['publicKey' => $this->vapidPublicKey]);
}
/**
* POST /api/push/subscribe
* Enregistre un abonnement push.
*
* {
* "endpoint": "https://...",
* "keys": { "auth": "...", "p256dh": "..." },
* "coastalPointId": "uuid" // optionnel
* }
*/
#[Route('/subscribe', name: 'subscribe', methods: ['POST'])]
public function subscribe(Request $request): JsonResponse
{
$limiter = $this->apiPushLimiter->create($request->getClientIp() ?? 'unknown');
if (!$limiter->consume()->isAccepted()) {
return $this->json(['error' => 'Too many requests'], 429);
}
$data = json_decode($request->getContent(), true);
if (!isset($data['endpoint'], $data['keys']['auth'], $data['keys']['p256dh'])) {
return $this->json(['error' => 'Missing required fields'], 422);
}
// Upsert : si l'endpoint existe déjà, on met à jour
$repo = $this->em->getRepository(PushSubscription::class);
$sub = $repo->findOneBy(['endpoint' => $data['endpoint']]) ?? new PushSubscription();
$sub->setEndpoint($data['endpoint']);
$sub->setAuthToken($data['keys']['auth']);
$sub->setP256dhKey($data['keys']['p256dh']);
if (!empty($data['coastalPointId'])) {
$spot = $this->em->getRepository(CoastalPoint::class)->find($data['coastalPointId']);
$sub->setCoastalPoint($spot);
}
$this->em->persist($sub);
$this->em->flush();
return $this->json(['id' => (string) $sub->getId()], 201);
}
/**
* DELETE /api/push/subscribe
* Supprime un abonnement push.
*/
#[Route('/subscribe', name: 'unsubscribe', methods: ['DELETE'])]
public function unsubscribe(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (empty($data['endpoint'])) {
return $this->json(['error' => 'Missing endpoint'], 422);
}
$sub = $this->em->getRepository(PushSubscription::class)
->findOneBy(['endpoint' => $data['endpoint']]);
if ($sub !== null) {
$this->em->remove($sub);
$this->em->flush();
}
return $this->json(null, 204);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Entity;
use App\Repository\PushSubscriptionRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: PushSubscriptionRepository::class)]
#[ORM\Index(columns: ['coastal_point_id'], name: 'idx_push_coastal_point')]
class PushSubscription
{
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
private ?Uuid $id = null;
#[ORM\Column(type: 'text', unique: true)]
private ?string $endpoint = null;
#[ORM\Column(type: 'text')]
private ?string $authToken = null;
#[ORM\Column(type: 'text')]
private ?string $p256dhKey = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?CoastalPoint $coastalPoint = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?Uuid { return $this->id; }
public function getEndpoint(): ?string { return $this->endpoint; }
public function setEndpoint(string $endpoint): static { $this->endpoint = $endpoint; return $this; }
public function getAuthToken(): ?string { return $this->authToken; }
public function setAuthToken(string $authToken): static { $this->authToken = $authToken; return $this; }
public function getP256dhKey(): ?string { return $this->p256dhKey; }
public function setP256dhKey(string $p256dhKey): static { $this->p256dhKey = $p256dhKey; return $this; }
public function getCoastalPoint(): ?CoastalPoint { return $this->coastalPoint; }
public function setCoastalPoint(?CoastalPoint $coastalPoint): static { $this->coastalPoint = $coastalPoint; return $this; }
public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; }
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Repository;
use App\Entity\PushSubscription;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class PushSubscriptionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, PushSubscription::class);
}
}

View File

@@ -6,6 +6,7 @@ use App\Entity\CoastalPoint;
use App\Entity\ImpactScore;
use App\Entity\SargassumForecast;
use App\Entity\SargassumObservation;
use App\Service\Push\PushNotificationService;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
@@ -31,11 +32,18 @@ class ImpactScoreService
private const MAX_APPROACH_KMH = 5; // 5 km/h = vitesse d'approche max
private const CACHE_TTL = 10_800; // 3h
// Bonus feedback : +8 pts si présence confirmée dans les 6h et dans rayon 20km
private const FEEDBACK_RADIUS_M = 20_000;
private const FEEDBACK_WINDOW_H = 6;
private const FEEDBACK_BONUS = 8;
private const HIGH_SCORE_THRESHOLD = 70;
public function __construct(
private Connection $connection,
private EntityManagerInterface $em,
private CacheInterface $cache,
private LoggerInterface $logger,
private PushNotificationService $push,
) {}
/**
@@ -61,6 +69,11 @@ class ImpactScoreService
$score = $this->computeScore($spot, $obsId, $forecastH6);
$this->persistScore($spot, $score);
$this->invalidateCache($spotId);
// Alerte push si score passe en "high"
if ($score['score'] >= self::HIGH_SCORE_THRESHOLD) {
$this->push->notifySpot($spot, $score['score'], $score['level']);
}
} catch (\Throwable $e) {
$this->logger->error('Score computation failed', [
'spot' => $spot->getName(),
@@ -124,7 +137,10 @@ class ImpactScoreService
$velocityScore = min(max($approachKmh, 0.0) / self::MAX_APPROACH_KMH, 1.0) * 20;
$trendScore = match ($trend) { 'increasing' => 10, 'stable' => 5, default => 0 };
$total = (int) round($distanceScore + $densityScore + $velocityScore + $trendScore);
// Bonus feedback terrain récent
$feedbackBonus = $this->getFeedbackBonus($spotId);
$total = (int) round($distanceScore + $densityScore + $velocityScore + $trendScore + $feedbackBonus);
$total = max(0, min(100, $total));
return [
@@ -254,6 +270,27 @@ class ImpactScoreService
return ['score' => 0, 'level' => 'low', 'distance' => null, 'density' => 0.0, 'trend' => 'stable'];
}
private function getFeedbackBonus(string $spotId): float
{
$windowStart = (new \DateTimeImmutable())->modify('-' . self::FEEDBACK_WINDOW_H . ' hours');
$row = $this->connection->fetchAssociative(
'SELECT COUNT(*) AS positive_count
FROM user_feedback uf
JOIN coastal_point cp ON cp.id = :spotId
WHERE uf.has_seaweed = true
AND uf.timestamp >= :since
AND ST_Distance(uf.location::geography, cp.geometry::geography) <= :radius',
[
'spotId' => $spotId,
'since' => $windowStart->format('Y-m-d H:i:s'),
'radius' => self::FEEDBACK_RADIUS_M,
]
);
return ($row !== false && (int) $row['positive_count'] > 0) ? (float) self::FEEDBACK_BONUS : 0.0;
}
private function findForecastByHorizon(array $forecasts, int $horizon): ?SargassumForecast
{
foreach ($forecasts as $f) {

View File

@@ -0,0 +1,5 @@
<?php
namespace App\Service\Ingestion;
class HighCloudCoverageException extends \RuntimeException {}

View File

@@ -26,8 +26,26 @@ class IngestionService
#[Autowire('%kernel.project_dir%')] private string $projectDir,
) {}
public function ingest(array $bbox, \DateTimeImmutable $date, string $source = 'Sentinel-2'): void
/**
* Tente l'ingestion Sentinel-2, puis Sentinel-3 OLCI en fallback si la
* couverture nuageuse est trop élevée ou si la scène S2 est indisponible.
*/
public function ingestWithFallback(array $bbox, \DateTimeImmutable $date): void
{
try {
$this->ingest($bbox, $date, 'Sentinel-2', 'sentinel-2-l2a');
} catch (HighCloudCoverageException $e) {
$this->logger->info('S2 cloud too high, falling back to Sentinel-3', ['reason' => $e->getMessage()]);
$this->ingest($bbox, $date, 'Sentinel-3', 'sentinel-3-olci');
}
}
public function ingest(
array $bbox,
\DateTimeImmutable $date,
string $source = 'Sentinel-2',
string $collection = 'sentinel-2-l2a',
): void {
$job = new DataIngestionJob();
$job->setSource($source);
$job->setTileId(sprintf('[%s]', implode(',', $bbox)));
@@ -38,14 +56,16 @@ class IngestionService
try {
// Étape 1 : vérification couverture nuageuse via Catalog API
$cloudCoverage = $this->sentinelHub->getCloudCoverage($bbox, $date);
$this->logger->info('Cloud coverage check', ['bbox' => $bbox, 'coverage' => $cloudCoverage]);
$cloudCoverage = $this->sentinelHub->getCloudCoverage($bbox, $date, $collection);
$this->logger->info('Cloud coverage check', ['bbox' => $bbox, 'coverage' => $cloudCoverage, 'collection' => $collection]);
if ($cloudCoverage > self::CLOUD_THRESHOLD) {
$job->setStatus('failed');
$job->setErrorMessage("Couverture nuageuse {$cloudCoverage}% > seuil " . self::CLOUD_THRESHOLD . '%');
$this->em->flush();
return;
throw new HighCloudCoverageException(
"Cloud coverage {$cloudCoverage}% exceeds threshold for collection {$collection}"
);
}
// Étape 2 : téléchargement du raster AFAI binaire
@@ -54,7 +74,7 @@ class IngestionService
$tifPath = $tmpDir . '/afai.tif';
$geojsonPath = $tmpDir . '/polygons.geojson';
$metadata = $this->sentinelHub->downloadAfaiBinary($bbox, $date, $tifPath);
$metadata = $this->sentinelHub->downloadAfaiBinary($bbox, $date, $tifPath, $collection);
$this->logger->info('GeoTIFF downloaded', ['path' => $tifPath, 'tile' => $metadata['tileId']]);
// Étape 3 : vectorisation via GDAL

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Service\Push;
use App\Entity\CoastalPoint;
use App\Entity\PushSubscription;
use Doctrine\ORM\EntityManagerInterface;
use Minishlink\WebPush\Subscription;
use Minishlink\WebPush\WebPush;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class PushNotificationService
{
public function __construct(
private EntityManagerInterface $em,
private LoggerInterface $logger,
#[Autowire('%env(VAPID_PUBLIC_KEY)%')] private string $vapidPublicKey,
#[Autowire('%env(VAPID_PRIVATE_KEY)%')] private string $vapidPrivateKey,
#[Autowire('%env(APP_PUBLIC_URL)%')] private string $appPublicUrl,
) {}
/**
* Envoie une alerte push à tous les abonnés d'un CoastalPoint.
*/
public function notifySpot(CoastalPoint $spot, int $score, string $level): void
{
$subscriptions = $this->em->getRepository(PushSubscription::class)
->findBy(['coastalPoint' => $spot]);
if (empty($subscriptions)) {
return;
}
$webPush = new WebPush([
'VAPID' => [
'subject' => $this->appPublicUrl,
'publicKey' => $this->vapidPublicKey,
'privateKey' => $this->vapidPrivateKey,
],
]);
$payload = json_encode([
'title' => '⚠️ Alerte sargasses — ' . $spot->getName(),
'body' => sprintf('Score %d/100 — %s. Vérifiez avant de partir.', $score, ucfirst($level)),
'url' => $this->appPublicUrl,
'spotId' => (string) $spot->getId(),
]);
$expired = [];
foreach ($subscriptions as $sub) {
$webPushSub = Subscription::create([
'endpoint' => $sub->getEndpoint(),
'keys' => [
'auth' => $sub->getAuthToken(),
'p256dh' => $sub->getP256dhKey(),
],
]);
$webPush->queueNotification($webPushSub, $payload);
}
foreach ($webPush->flush() as $report) {
if ($report->isSubscriptionExpired()) {
// Supprimer les abonnements expirés
foreach ($subscriptions as $sub) {
if ($sub->getEndpoint() === $report->getRequest()->getUri()->__toString()) {
$expired[] = $sub;
}
}
} elseif (!$report->isSuccess()) {
$this->logger->warning('Push notification failed', [
'reason' => $report->getReason(),
]);
}
}
foreach ($expired as $sub) {
$this->em->remove($sub);
}
if (!empty($expired)) {
$this->em->flush();
}
$this->logger->info('Push notifications sent', [
'spot' => $spot->getName(),
'count' => count($subscriptions) - count($expired),
]);
}
}

View File

@@ -43,11 +43,17 @@ class SentinelHubClient
}
/**
* Retourne la couverture nuageuse (%) de la meilleure scène disponible
* pour le bbox et la date donnés.
* Retourne la couverture nuageuse (%) de la meilleure scène disponible.
* collection : 'sentinel-2-l2a' (défaut) ou 'sentinel-3-olci'
*/
public function getCloudCoverage(array $bbox, \DateTimeImmutable $date): float
public function getCloudCoverage(array $bbox, \DateTimeImmutable $date, string $collection = 'sentinel-2-l2a'): float
{
// Sentinel-3 OLCI n'expose pas eo:cloud_cover dans le Catalog ; on renvoie 0
// pour laisser le Process API décider.
if ($collection === 'sentinel-3-olci') {
return 0.0;
}
$token = $this->getToken();
$dateStr = $date->format('Y-m-d');
@@ -56,7 +62,7 @@ class SentinelHubClient
'json' => [
'bbox' => $bbox,
'datetime' => "{$dateStr}T00:00:00Z/{$dateStr}T23:59:59Z",
'collections' => ['sentinel-2-l2a'],
'collections' => [$collection],
'limit' => 1,
'sortby' => [['field' => 'eo:cloud_cover', 'direction' => 'asc']],
],
@@ -73,14 +79,22 @@ class SentinelHubClient
/**
* Télécharge un raster UINT8 binaire (1=sargasse, 0=non, 255=nuage/nodata)
* calculé via evalscript AFAI, et le sauvegarde dans $outputPath.
* Retourne les métadonnées de la scène.
* calculé via evalscript AFAI/FAI, et le sauvegarde dans $outputPath.
* collection : 'sentinel-2-l2a' (défaut) ou 'sentinel-3-olci'
*/
public function downloadAfaiBinary(array $bbox, \DateTimeImmutable $date, string $outputPath): array
{
public function downloadAfaiBinary(
array $bbox,
\DateTimeImmutable $date,
string $outputPath,
string $collection = 'sentinel-2-l2a',
): array {
$token = $this->getToken();
$dateStr = $date->format('Y-m-d');
$evalscript = $collection === 'sentinel-3-olci'
? $this->getFaiBinaryEvalscriptS3()
: $this->getAfaiBinaryEvalscript();
$response = $this->httpClient->request('POST', self::PROCESS_URL, [
'headers' => ['Authorization' => "Bearer {$token}"],
'json' => [
@@ -90,7 +104,7 @@ class SentinelHubClient
'properties' => ['crs' => 'http://www.opengis.net/def/crs/OGC/1.3/CRS84'],
],
'data' => [[
'type' => 'sentinel-2-l2a',
'type' => $collection,
'dataFilter' => [
'timeRange' => [
'from' => "{$dateStr}T00:00:00Z",
@@ -107,7 +121,7 @@ class SentinelHubClient
'format' => ['type' => 'image/tiff'],
]],
],
'evalscript' => $this->getAfaiBinaryEvalscript(),
'evalscript' => $evalscript,
],
]);
@@ -117,7 +131,7 @@ class SentinelHubClient
return [
'tileId' => $headers['x-process-request-id'][0] ?? uniqid('tile_'),
'afaiMean' => 0.0, // calculé en phase post-traitement si nécessaire
'afaiMean' => 0.0,
'afaiStd' => 0.0,
];
}
@@ -126,7 +140,6 @@ class SentinelHubClient
{
// Longueurs d'onde Sentinel-2 (nm) — constantes capteur
// B04 (Red) : 664.5 | B08 (NIR) : 832.8 | B11 (SWIR1) : 1613.7
// ratio = (λ_NIR - λ_RED) / (λ_SWIR1 - λ_RED)
return <<<'EVALSCRIPT'
//VERSION=3
function setup() {
@@ -146,7 +159,40 @@ function evaluatePixel(sample) {
const afai = sample.B08 - sample.B04 - (sample.B11 - sample.B04) * ratio;
return [afai > 0.005 ? 1 : 0]; // 1 = sargasse détectée
return [afai > 0.005 ? 1 : 0];
}
EVALSCRIPT;
}
/**
* Evalscript Sentinel-3 OLCI — FAI (Floating Algae Index) via MCI.
*
* OLCI bands (nm) : Oa08=665 (Red), Oa11=708.75 (RedEdge), Oa17=865 (NIR)
* MCI = Oa17 Oa08 (Oa11 Oa08) × (865665)/(708.75665)
* Seuil empirique retenu : MCI > 0.008
*/
private function getFaiBinaryEvalscriptS3(): string
{
return <<<'EVALSCRIPT'
//VERSION=3
function setup() {
return {
input: [{ bands: ["B08", "B11", "B17"], units: "REFLECTANCE" }],
output: { bands: 1, sampleType: "UINT8" }
};
}
function evaluatePixel(sample) {
const lambdaRed = 665.0;
const lambdaRedEdge = 708.75;
const lambdaNir = 865.0;
const ratio = (lambdaNir - lambdaRed) / (lambdaRedEdge - lambdaRed);
// MCI : baseline interpolée entre Red et RedEdge
const baseline = sample.B08 + (sample.B11 - sample.B08) * (lambdaNir - lambdaRed) / (lambdaRedEdge - lambdaRed);
const mci = sample.B17 - baseline;
return [mci > 0.008 ? 1 : 0];
}
EVALSCRIPT;
}