From ff104a11ca5bde1d67a7345a032b0946a91495b3 Mon Sep 17 00:00:00 2001 From: Thierry Bugier Date: Thu, 2 Apr 2026 13:52:57 +0200 Subject: [PATCH 1/4] fix(Location): reset source_zone again --- src/Location.php | 2 +- tests/units/LocationTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Location.php b/src/Location.php index 4f89b7cc..6dfb3b85 100644 --- a/src/Location.php +++ b/src/Location.php @@ -116,7 +116,7 @@ public function prepareInputForUpdate($input) } } - if (($input['plugin_carbon_sources_id'] ?? 0) === 0) { + if (($input['plugin_carbon_sources_id'] ?? 0) == 0) { $input['plugin_carbon_sources_zones_id'] = 0; } diff --git a/tests/units/LocationTest.php b/tests/units/LocationTest.php index 69684fc0..7596a3af 100644 --- a/tests/units/LocationTest.php +++ b/tests/units/LocationTest.php @@ -80,13 +80,13 @@ public function test_prepareInputForUpdate_resets_source_zone_when_source_is_0() // This happens when the user removes the affectation of a location to a carbon intensity source and zone $input = [ 'id' => 1, - getForeignKeyFieldForItemType(Source::class) => 0, + getForeignKeyFieldForItemType(Source::class) => '0', ]; $instance = new Location(); $result = $instance->prepareInputForUpdate($input); $expected = [ 'id' => 1, - getForeignKeyFieldForItemType(Source::class) => 0, + getForeignKeyFieldForItemType(Source::class) => '0', getForeignKeyFieldForItemType(Source_Zone::class) => 0, ]; $this->assertSame($expected, $result); From 01a7a3078aeb2d4cb26375a86b42a75a2cbe77d1 Mon Sep 17 00:00:00 2001 From: Thierry Bugier Date: Thu, 2 Apr 2026 10:57:18 +0200 Subject: [PATCH 2/4] fix(config): geocoding checkbox description --- src/DataSource/Lca/Boaviztapi/Config.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/DataSource/Lca/Boaviztapi/Config.php b/src/DataSource/Lca/Boaviztapi/Config.php index aff4dc0e..07e61aaa 100644 --- a/src/DataSource/Lca/Boaviztapi/Config.php +++ b/src/DataSource/Lca/Boaviztapi/Config.php @@ -73,7 +73,7 @@ public function getConfigTemplate(): string } $twig .= << -

{{ __('Geocoding converts a location into a ISO 3166 (3 letters) code. Boavizta needs this to determine usage impacts of assets. This feature sends the address stored in a location to nominatim.org service. If this is an issue, you can disable it below, and fill the coutry code manually.', 'carbon') }}

+

{{ __('Geocoding converts a location into a ISO 3166 (3 letters) country code. Boavizta needs this to determine usage impacts of assets. This feature sends the address stored in a location to nominatim.org service. If this is an issue, you can disable it below, and fill the coutry code manually.', 'carbon') }}

{{ fields.checkboxField( From 2214b5c9e00029362bc92a29060ce5fb89aae808 Mon Sep 17 00:00:00 2001 From: Thierry Bugier Date: Mon, 30 Mar 2026 11:28:12 +0200 Subject: [PATCH 3/4] fix(Datasource\CarbonIntensity\ElectricityMaps\Client): allow paid API key --- src/CarbonIntensity.php | 124 ++++++-------- src/Command/CollectCarbonIntensityCommand.php | 32 ++-- src/Config.php | 18 ++- src/CronTask.php | 12 +- .../CarbonIntensity/AbstractClient.php | 72 ++++++--- .../CarbonIntensity/ClientInterface.php | 17 +- .../ElectricityMaps/Client.php | 123 ++++++++------ .../ElectricityMaps/Config.php | 21 ++- src/DataSource/CarbonIntensity/Rte/Client.php | 50 +++--- src/DataSource/RestApiClient.php | 3 +- src/Source_Zone.php | 3 +- src/Toolbox.php | 2 +- tests/units/CarbonIntensityTest.php | 151 +++++++++--------- tests/units/CronTaskTest.php | 111 ++++++++----- .../ElectricityMaps/ClientTest.php | 4 +- .../CarbonIntensity/Rte/ClientTest.php | 41 +++-- 16 files changed, 463 insertions(+), 321 deletions(-) diff --git a/src/CarbonIntensity.php b/src/CarbonIntensity.php index 39342839..cfc706a7 100644 --- a/src/CarbonIntensity.php +++ b/src/CarbonIntensity.php @@ -41,7 +41,6 @@ use Exception; use Glpi\DBAL\QueryParam; use GlpiPlugin\Carbon\DataSource\CarbonIntensity\ClientInterface; -use RuntimeException; use Symfony\Component\Console\Helper\ProgressBar; /** @@ -124,15 +123,16 @@ public function rawSearchOptions() /** * get carbon intensity dates for a source and a zone * - * @param string $zone_name Zone to examinate - * @param string $source_name Source to examinate + * @param Source_Zone $source_zone Source_Zone to examinate * @return array */ - private function getKnownDatesQuery(string $zone_name, string $source_name) + private function getKnownDatesQuery(Source_Zone $source_zone) { $intensity_table = CarbonIntensity::getTable(); $source_table = Source::getTable(); $zone_table = Zone::getTable(); + $source_fk = getForeignKeyFieldForItemType(Source::class); + $zone_fk = getForeignKeyFieldForItemType(Zone::class); return [ 'SELECT' => [$intensity_table => ['id', 'date']], 'FROM' => $intensity_table, @@ -151,8 +151,8 @@ private function getKnownDatesQuery(string $zone_name, string $source_name) ], ], 'WHERE' => [ - Source::getTableField('name') => $source_name, - Zone::getTableField('name') => $zone_name, + $source_fk => $source_zone->fields[$source_fk], + $zone_fk => $source_zone->fields[$zone_fk], ], ]; } @@ -160,16 +160,15 @@ private function getKnownDatesQuery(string $zone_name, string $source_name) /** * Get the last known date of carbon emissiosn * - * @param string $zone_name Zone to examinate - * @param string $source_name Source to examinate + * @param Source_Zone $source_zone Source_Zone to examinate * @return DateTimeImmutable */ - public function getLastKnownDate(string $zone_name, string $source_name): ?DateTimeImmutable + public function getLastKnownDate(Source_Zone $source_zone): ?DateTimeImmutable { /** @var DBmysql $DB */ global $DB; - $request = $this->getKnownDatesQuery($zone_name, $source_name); + $request = $this->getKnownDatesQuery($source_zone); $request['ORDER'] = CarbonIntensity::getTableField('date') . ' DESC'; $request['LIMIT'] = '1'; $result = $DB->request($request)->current(); @@ -182,16 +181,15 @@ public function getLastKnownDate(string $zone_name, string $source_name): ?DateT /** * Get the first known date of carbon emissiosn * - * @param string $zone_name Zone to examinate - * @param string $source_name Source to examinate + * @param Source_Zone $source_zone * @return DateTimeImmutable */ - public function getFirstKnownDate(string $zone_name, string $source_name): ?DateTimeImmutable + public function getFirstKnownDate(Source_Zone $source_zone): ?DateTimeImmutable { /** @var DBmysql $DB */ global $DB; - $request = $this->getKnownDatesQuery($zone_name, $source_name); + $request = $this->getKnownDatesQuery($source_zone); $request['ORDER'] = CarbonIntensity::getTableField('date') . ' ASC'; $request['LIMIT'] = '1'; $result = $DB->request($request)->current(); @@ -206,29 +204,27 @@ public function getFirstKnownDate(string $zone_name, string $source_name): ?Date * Download data for a single zone * * @param ClientInterface $data_source - * @param string $zone_name zone name + * @param Source_Zone $source_zone * @param int $limit maximum count of items to process * @param ProgressBar $progress_bar progress bar to update (CLI mode only) * @return int count of item downloaded */ - public function downloadOneZone(ClientInterface $data_source, string $zone_name, int $limit = 0, ?ProgressBar $progress_bar = null): int + public function downloadOneZone(ClientInterface $data_source, Source_Zone $source_zone, int $limit = 0, ?ProgressBar $progress_bar = null): int { - $start_date = $this->getDownloadStartDate(); + $start_date = max($this->getDownloadStartDate(), $data_source->getHardStartDate()); $total_count = 0; // Check if there are gaps to fill - $source = new Source(); - $source->getFromDBByCrit(['name' => $data_source->getSourceName()]); - $zone = new Zone(); - $zone->getFromDBByCrit(['name' => $zone_name]); - $gaps = $this->findGaps($source->getID(), $zone->getID(), $start_date); + $gaps = $this->findGaps($source_zone, $start_date); if (count($gaps) === 0) { // Log a notice specifying the source and the zone + $zone = new Zone(); + $zone->getFromDBByCrit(['id' => $source_zone->fields[Zone::getForeignKeyField()]]); trigger_error(sprintf( "No gap to fill for source %s and zone %s between %s and %s", $data_source->getSourceName(), - $zone_name, + $zone->fields['name'], $start_date->format('Y-m-d'), 'now' ), E_USER_WARNING); @@ -249,7 +245,7 @@ public function downloadOneZone(ClientInterface $data_source, string $zone_name, foreach ($gaps as $gap) { $gap_start = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $gap['start']); $gap_end = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $gap['end']); - $count = $data_source->fullDownload($zone_name, $gap_start, $gap_end, $this, $limit, $progress_bar); + $count = $data_source->fullDownload($source_zone, $gap_start, $gap_end, $this, $limit, $progress_bar); $total_count += $count; if ($total_count >= $limit) { return $total_count; @@ -281,65 +277,48 @@ public function getDownloadStartDate(): ?DateTimeImmutable return $start_date; } - /** - * Get date where data download shall end, excluding the incremental download mode for the specified data source - * - * @param string $zone_name zone to examine - * @param ClientInterface $data_source data source - * @return DateTimeImmutable - */ - public function getDownloadStopDate(string $zone_name, ClientInterface $data_source): DateTimeImmutable - { - $stop_date = $data_source->getMaxIncrementalAge(); - $first_known_intensity_date = $this->getFirstKnownDate($zone_name, $data_source->getSourceName()); - if ($first_known_intensity_date !== null) { - $first_known_intensity_date = $first_known_intensity_date->sub(new DateInterval('PT1H')); - $stop_date = min($stop_date, $first_known_intensity_date); - } - - return $stop_date; - } + // /** + // * Get date where data download shall end, excluding the incremental download mode for the specified data source + // * + // * @param string $zone_name zone to examine + // * @param ClientInterface $data_source data source + // * @return DateTimeImmutable + // */ + // public function getDownloadStopDate(string $zone_name, ClientInterface $data_source): DateTimeImmutable + // { + // $stop_date = $data_source->getMaxIncrementalAge(); + // $first_known_intensity_date = $this->getFirstKnownDate($zone_name, $data_source->getSourceName()); + // if ($first_known_intensity_date !== null) { + // $first_known_intensity_date = $first_known_intensity_date->sub(new DateInterval('PT1H')); + // $stop_date = min($stop_date, $first_known_intensity_date); + // } + + // return $stop_date; + // } /** * Save in database the carbon intensities * Give up on failures * - * @param string $zone_name name of the zone to store intensities - * @param string $source_name name of the source to store intensities + * @param Source_zone $source_zone Source_zone * @param array $data as an array of arrays ['datetime' => string, 'intensity' => float] * @return int count of actually saved items, */ - public function save(string $zone_name, string $source_name, array $data): int + public function save(Source_zone $source_zone, array $data): int { /** @var DBmysql $DB */ global $DB; $count = 0; - $source = new Source(); - $source->getFromDBByCrit([ - 'name' => $source_name, - ]); - if ($source->isNewItem()) { - throw new RuntimeException('Attempt to save carbon intensity with a source which is not in the database'); - // trigger_error('Attempt to save carbon intensity with a source which is not in the database', E_USER_ERROR); - // return 0; - } - $zone = new Zone(); - $zone->getFromDBByCrit([ - 'name' => $zone_name, - ]); - if ($zone->isNewItem()) { - throw new RuntimeException('Attempt to save carbon intensity with a zone which is not in the database'); - // trigger_error('Attempt to save carbon intensity with a zone which is not in the database', E_USER_ERROR); - // return 0; - } + $source_fk = getForeignKeyFieldForItemType(Source::class); + $zone_fk = getForeignKeyFieldForItemType(Zone::class); $query = $DB->buildInsert( CarbonIntensity::getTable(), [ 'date' => new QueryParam(), - Source::getForeignKeyField() => new QueryParam(), - Zone::getForeignKeyField() => new QueryParam(), + $source_fk => new QueryParam(), + $zone_fk => new QueryParam(), 'intensity' => new QueryParam(), 'data_quality' => new QueryParam(), ], @@ -351,8 +330,8 @@ public function save(string $zone_name, string $source_name, array $data): int $stmt->bind_param( 'siidi', $intensity['datetime'], - $source->fields['id'], - $zone->fields['id'], + $source_zone->fields[$source_fk], + $source_zone->fields[$zone_fk], $intensity['intensity'], $intensity['data_quality'] ); @@ -371,17 +350,18 @@ public function save(string $zone_name, string $source_name, array $data): int /** * Gets date intervals where data are missing * - * @param int $source_id - * @param int $zone_id + * @param Source_Zone $source_zone * @param DateTimeInterface $start * @param DateTimeInterface|null $stop * @return array */ - public function findGaps(int $source_id, int $zone_id, DateTimeInterface $start, ?DateTimeInterface $stop = null): array + public function findGaps(Source_Zone $source_zone, DateTimeInterface $start, ?DateTimeInterface $stop = null): array { + $source_fk = getForeignKeyFieldForItemType(Source::class); + $zone_fk = getForeignKeyFieldForItemType(Zone::class); $criteria = [ - Source::getForeignKeyField() => $source_id, - Zone::getForeignKeyField() => $zone_id, + $source_fk => $source_zone->fields[$source_fk], + $zone_fk => $source_zone->fields[$zone_fk], ]; $interval = new DateInterval('PT1H'); return Toolbox::findTemporalGapsInTable(self::getTable(), $start, $interval, $stop, $criteria); diff --git a/src/Command/CollectCarbonIntensityCommand.php b/src/Command/CollectCarbonIntensityCommand.php index f60112d8..23453cdc 100644 --- a/src/Command/CollectCarbonIntensityCommand.php +++ b/src/Command/CollectCarbonIntensityCommand.php @@ -59,6 +59,8 @@ class CollectCarbonIntensityCommand extends AbstractCommand { /** @var int ID of the data source being processed */ private int $source_id; + /** @var Source_Zone The relatin between a source and a zone to describe which data to download and save */ + private Source_Zone $source_zone; private ?ClientInterface $client = null; private array $zones = []; @@ -122,18 +124,13 @@ protected function execute(InputInterface $input, OutputInterface $output) $message = __('Creating data source name', 'carbon'); $output->writeln("$message"); - // Create the source if it does not exist + // Check the source exists $data_source = new Source(); $source_name = $input->getArgument('source'); if (!$data_source->getFromDBByCrit(['name' => $source_name])) { - $data_source->add([ - 'name' => $source_name, - ]); - if ($data_source->isNewItem()) { - $message = __("Source not found", 'carbon'); - $output->writeln("$message"); - return Command::FAILURE; - } + $message = __("This source does not exist", 'casrbon'); + $output->writeln("$message"); + return Command::FAILURE; } $this->source_id = $data_source->getID(); @@ -143,7 +140,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $zone->getFromDBByCrit(['name' => $this->zones[$zone_code]]); $carbon_intensity = new CarbonIntensity(); - // Create relation between source and zone if t does not exist + // Check the relation between source and zone $source_zone = new Source_Zone(); $input = [ $data_source::getForeignKeyField() => $data_source->getID(), @@ -151,14 +148,11 @@ protected function execute(InputInterface $input, OutputInterface $output) ]; $source_zone->getFromDbByCrit($input); if ($source_zone->isNewItem()) { - $input['is_download_enabled'] = 1; - $input['code'] = $zone_code; - if ($source_zone->add($input) === false) { - $message = __("Creation of relation between source and zone failed", 'carbon'); - $output->writeln("$message"); - return Command::FAILURE; - } + $message = __("The zone is not handled by the data source", 'casrbon'); + $output->writeln("$message"); + return Command::FAILURE; } + $this->source_zone = $source_zone; $message = __("Reading data...", 'carbon'); $output->writeln("$message"); @@ -172,11 +166,11 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->client->disableCache(); } - $carbon_intensity->downloadOneZone($this->client, $this->zones[$zone_code], 0, new ProgressBar($this->output)); + $carbon_intensity->downloadOneZone($this->client, $this->source_zone, 0, new ProgressBar($this->output)); // Find start and stop dates to cover $start_date = $carbon_intensity->getDownloadStartDate(); - $gaps = $carbon_intensity->findGaps($this->source_id, $zone->getID(), $start_date); + $gaps = $carbon_intensity->findGaps($this->source_zone, $start_date); // Count the hours not covered by any sample $not_downlaoded_hours = 0; diff --git a/src/Config.php b/src/Config.php index c49e048b..1a5c6c85 100644 --- a/src/Config.php +++ b/src/Config.php @@ -99,16 +99,21 @@ public function showForm($ID, $options = []) $canedit = Session::haveRight(Config::$rightname, UPDATE); // Get config template foreach LCA data source + $secured_config = []; $include_configs = []; foreach (CarbonIntensityClientFactory::getConfigTypes() as $config_type) { $config = new $config_type(); + $secured_config += $config->getSecuredConfigs(); $include_configs[] = $config->getConfigTemplate(); } foreach (LcaClientFactory::getConfigTypes() as $config_type) { $config = new $config_type(); + $secured_config += $config->getSecuredConfigs(); $include_configs[] = $config->getConfigTemplate(); } + $current_config = array_diff_key($current_config, array_flip($secured_config)); + $hide_boaviztapi_base_url = (getenv(self::ENV_BOAVIZTAPI_BASE_URL) !== false); $renderer = TemplateRenderer::getInstance(); $environment = $renderer->getEnvironment(); @@ -269,11 +274,22 @@ public static function getPluginConfigurationValue(string $name): ?string /** * Set a plugin configuration value * - * @param array $values key => value pairs to set + * @param array $values key => value pairs to set * @return void */ public static function setPluginConfigurationValues(array $values = []): void { GlpiConfig::setConfigurationValues(self::CONFIG_CONTEXT, $values); } + + /** + * Delete plugin configuration values + * + * @param array $values names of values to delete + * @return void + */ + public static function deletePluginConfigurationValues(array $values) + { + GlpiConfig::deleteConfigurationValues(self::CONFIG_CONTEXT, $values); + } } diff --git a/src/CronTask.php b/src/CronTask.php index 14caa8b5..fbc8d3a9 100644 --- a/src/CronTask.php +++ b/src/CronTask.php @@ -273,18 +273,18 @@ public static function downloadCarbonIntensityFromSource(GlpiCronTask $task, Cli $task->addVolume($done_count); } - $zones = $data_source->getZones(['is_download_enabled' => 1]); - if (count($zones) === 0) { + $rows = $data_source->getSourceZones(['is_download_enabled' => 1]); + if (count($rows) === 0) { trigger_error(__('No zone to download', 'carbon'), E_USER_WARNING); return 0; } - $limit_per_zone = max(1, floor(($remaining) / count($zones))); + $limit_per_zone = max(1, floor(($remaining) / count($rows))); $count = 0; - foreach ($zones as $zone) { - $zone_name = $zone['name']; + foreach ($rows as $row) { + $source_zone = Source_Zone::getById($row['id']); try { - $added = $intensity->downloadOneZone($data_source, $zone_name, $limit_per_zone); + $added = $intensity->downloadOneZone($data_source, $source_zone, $limit_per_zone); } catch (RuntimeException $e) { trigger_error($e->getMessage(), E_USER_WARNING); continue; diff --git a/src/DataSource/CarbonIntensity/AbstractClient.php b/src/DataSource/CarbonIntensity/AbstractClient.php index 5655b77c..fcc5829b 100644 --- a/src/DataSource/CarbonIntensity/AbstractClient.php +++ b/src/DataSource/CarbonIntensity/AbstractClient.php @@ -36,6 +36,7 @@ use DateInterval; use DateTime; use DateTimeImmutable; +use DateTimeZone; use DBmysql; use Generator; use GlpiPlugin\Carbon\CarbonIntensity; @@ -61,24 +62,24 @@ abstract protected function formatOutput(array $response, int $step): array; * Download all data for a single day from the datasource * * @param DateTimeImmutable $day - * @param string $zone + * @param Source_Zone $source_zone * @return array * * @throws AbortException if an error requires to stop all subsequent fetches */ - abstract public function fetchDay(DateTimeImmutable $day, string $zone): array; + abstract public function fetchDay(DateTimeImmutable $day, Source_Zone $source_zone): array; /** * Download a range if data from the data source * * @param DateTimeImmutable $start * @param DateTimeImmutable $stop - * @param string $zone + * @param Source_Zone $source_zone * @return array * * @throws AbortException if an error requires to stop all subsequent fetches */ - abstract public function fetchRange(DateTimeImmutable $start, DateTimeImmutable $stop, string $zone): array; + abstract public function fetchRange(DateTimeImmutable $start, DateTimeImmutable $stop, Source_Zone $source_zone): array; public function disableCache() { @@ -139,7 +140,9 @@ public function getZones(array $crit = []): array $zone_fk = Zone::getForeignKeyField(); $source_zone_table = Source_Zone::getTable(); $iterator = $DB->request([ - 'SELECT' => Zone::getTableField('name'), + 'SELECT' => [ + Zone::getTableField('name'), + ], 'FROM' => $zone_table, 'INNER JOIN' => [ $source_zone_table => [ @@ -163,7 +166,44 @@ public function getZones(array $crit = []): array return iterator_to_array($iterator); } - public function fullDownload(string $zone, DateTimeImmutable $start_date, DateTimeImmutable $stop_date, CarbonIntensity $intensity, int $limit = 0, ?ProgressBar $progress_bar = null): int + public function getSourceZones(array $crit = []): array + { + /** @var DBmysql $DB */ + global $DB; + + $source_table = Source::getTable(); + $source_fk = Source::getForeignKeyField(); + $zone_table = Zone::getTable(); + $zone_fk = Zone::getForeignKeyField(); + $source_zone_table = Source_Zone::getTable(); + $iterator = $DB->request([ + 'SELECT' => [ + getTableForItemType(Source_Zone::class) . '.*', + ], + 'FROM' => $zone_table, + 'INNER JOIN' => [ + $source_zone_table => [ + 'ON' => [ + $zone_table => 'id', + $source_zone_table => $zone_fk, + ], + ], + $source_table => [ + 'ON' => [ + $source_table => 'id', + $source_zone_table => $source_fk, + ], + ], + ], + 'WHERE' => [ + Source::getTableField('name') => $this->getSourceName(), + ] + $crit, + ]); + + return iterator_to_array($iterator); + } + + public function fullDownload(Source_Zone $source_zone, DateTimeImmutable $start_date, DateTimeImmutable $stop_date, CarbonIntensity $intensity, int $limit = 0, ?ProgressBar $progress_bar = null): int { if ($start_date >= $stop_date) { return 0; @@ -193,17 +233,14 @@ public function fullDownload(string $zone, DateTimeImmutable $start_date, DateTi $data = $this->fetchRange( DateTimeImmutable::createFromMutable($current_date), DateTimeImmutable::createFromMutable($next_month), - $zone + $source_zone ); } catch (AbortException $e) { break; } if (count($data) > 0) { $data = $this->formatOutput($data, $this->step); - if (!isset($data[$zone])) { - break; - } - $saved = $intensity->save($zone, $this->getSourceName(), $data[$zone]); + $saved = $intensity->save($source_zone, $data); if ($progress_bar) { $progress_bar->advance($saved); } @@ -222,7 +259,7 @@ public function fullDownload(string $zone, DateTimeImmutable $start_date, DateTi return $saved > 0 ? $count : -$count; } - public function incrementalDownload(string $zone, DateTimeImmutable $start_date, CarbonIntensity $intensity, int $limit = 0): int + public function incrementalDownload(Source_Zone $source_zone, DateTimeImmutable $start_date, CarbonIntensity $intensity, int $limit = 0): int { $end_date = new DateTimeImmutable('now'); @@ -230,15 +267,12 @@ public function incrementalDownload(string $zone, DateTimeImmutable $start_date, $saved = 0; foreach ($this->sliceDateRangeByDay($start_date, $end_date) as $slice) { try { - $data = $this->fetchDay($slice, $zone); + $data = $this->fetchDay($slice, $source_zone); } catch (AbortException $e) { throw $e; } $data = $this->formatOutput($data, $this->step); - if (!isset($data[$zone])) { - continue; - } - $saved = $intensity->save($zone, $this->getSourceName(), $data[$zone]); + $saved = $intensity->save($source_zone, $data); $count += abs($saved); if ($limit > 0 && $count >= $limit) { return $saved > 0 ? $count : -$count; @@ -325,9 +359,9 @@ protected function toggleZoneDownload(Zone $zone, Source $source, ?bool $state): return $source_zone->toggleZone($state); } - protected function getCacheFilename(string $base_dir, DateTimeImmutable $start, DateTimeImmutable $end): string + protected function getCacheFilename(string $base_dir, DateTimeImmutable $start, DateTimeImmutable $end, DateTimeZone $timezone): string { - $timezone_name = $start->getTimezone()->getName(); + $timezone_name = $timezone->getName(); $timezone_name = str_replace('/', '-', $timezone_name); return sprintf( '%s/%s_%s_%s.json', diff --git a/src/DataSource/CarbonIntensity/ClientInterface.php b/src/DataSource/CarbonIntensity/ClientInterface.php index 308cb783..16bdae9d 100644 --- a/src/DataSource/CarbonIntensity/ClientInterface.php +++ b/src/DataSource/CarbonIntensity/ClientInterface.php @@ -34,6 +34,7 @@ use DateTimeImmutable; use GlpiPlugin\Carbon\CarbonIntensity; +use GlpiPlugin\Carbon\Source_Zone; use Symfony\Component\Console\Helper\ProgressBar; /** @@ -161,6 +162,14 @@ public function getSupportedZones(): array; */ public function getZones(array $crit = []): array; + /** + * get relations between the source and the zones + * + * @param array $crit + * @return array + */ + public function getSourceZones(array $crit = []): array; + public function getMaxIncrementalAge(): DateTimeImmutable; /** @@ -175,7 +184,7 @@ public function getDataInterval(): string; * If the returned count is negative, then something went wrong * and the absolute value of the count tells how many items were saved * - * @param string $zone + * @param Source_Zone $source_zone * @param DateTimeImmutable $start_date date where the download must start * @param DateTimeImmutable $stop_date date where the download must start * @param CarbonIntensity $intensity Instance used to update the database @@ -183,16 +192,16 @@ public function getDataInterval(): string; * @param ProgressBar $progress progress bar to update during the download (CLI) * @return int count of successfully saved items */ - public function fullDownload(string $zone, DateTimeImmutable $start_date, DateTimeImmutable $stop_date, CarbonIntensity $intensity, int $limit = 0, ?ProgressBar $progress = null): int; + public function fullDownload(Source_Zone $source_zone, DateTimeImmutable $start_date, DateTimeImmutable $stop_date, CarbonIntensity $intensity, int $limit = 0, ?ProgressBar $progress = null): int; /** * Download recent carbon intensity data day by day * - * @param string $zone zone to process + * @param Source_Zone $source_zone * @param DateTimeImmutable $start_date DAte where the downlos must begin * @param CarbonIntensity $intensity Instance of CarbonIntensity to use to save data * @param int $limit maximum count of items to process * @return int count of processed items. Negative count on failure */ - public function incrementalDownload(string $zone, DateTimeImmutable $start_date, CarbonIntensity $intensity, int $limit = 0): int; + public function incrementalDownload(Source_Zone $source_zone, DateTimeImmutable $start_date, CarbonIntensity $intensity, int $limit = 0): int; } diff --git a/src/DataSource/CarbonIntensity/ElectricityMaps/Client.php b/src/DataSource/CarbonIntensity/ElectricityMaps/Client.php index b21410ce..a70d1dd1 100644 --- a/src/DataSource/CarbonIntensity/ElectricityMaps/Client.php +++ b/src/DataSource/CarbonIntensity/ElectricityMaps/Client.php @@ -114,29 +114,32 @@ public function createZones(): int try { $zones = $this->queryZones(); } catch (RuntimeException $e) { - return 0; + return -1; } $count = 0; $failed = false; foreach ($zones as $zone_key => $zone_spec) { - $zone_input = [ - 'name' => $zone_spec['zoneName'], - ]; $zone = new Zone(); - if ($zone->getFromDbByCrit($zone_input) === false) { - if ($zone->add($zone_input) === false) { - $failed = true; - continue; - } + $zone->getOrCreate([], [ + 'name' => $zone_spec['zoneName'], + ]); + if ($zone->isNewItem()) { + $failed = true; + continue; } $source_zone = new Source_Zone(); - $source_zone->add([ - Source::getForeignKeyField() => $source_id, - Zone::getForeignKeyField() => $zone->getID(), + $source_zone->getOrCreate([ 'code' => $zone_key, 'is_download_enabled' => Toolbox::isLocationExistForZone($zone->fields['name']), + ], [ + Source::getForeignKeyField() => $source_id, + Zone::getForeignKeyField() => $zone->getID(), ]); + if ($source_zone->isNewItem()) { + $failed = true; + continue; + } $count++; } @@ -198,10 +201,8 @@ public function getSupportedZones(): array * * The method fetches the intensities for the date range specified in argument. */ - public function fetchDay(DateTimeImmutable $day, string $zone): array + public function fetchDay(DateTimeImmutable $day, Source_Zone $source_zone): array { - $source_zone = new Source_Zone(); - $source_zone->getFromDbBySourceAndZone($this->getSourceName(), $zone); $zone_code = $source_zone->fields['code']; if ($zone_code === null) { @@ -252,26 +253,27 @@ public function fetchDay(DateTimeImmutable $day, string $zone): array // Filter out already existing entries $carbon_intensity = new CarbonIntensity(); - $last_known_date = $carbon_intensity->getLastKnownDate($zone, $this->getSourceName()); + $last_known_date = $carbon_intensity->getLastKnownDate($source_zone); $intensities = array_filter($intensities, function ($intensity) use ($last_known_date) { $intensity_date = DateTime::createFromFormat('Y-m-d\TH:i:s', $intensity['datetime']); return $intensity_date > $last_known_date; }); + $zone = Zone::getById($source_zone->fields[getForeignKeyFieldForItemType(Zone::class)]); + $zone_name = $zone->fields['name']; return [ 'source' => $this->getSourceName(), - $zone => $intensities, + $zone_name => $intensities, ]; } - public function fetchRange(DateTimeImmutable $start, DateTimeImmutable $stop, string $zone): array + public function fetchRange(DateTimeImmutable $start, DateTimeImmutable $stop, Source_Zone $source_zone): array { - // TODO: get zones from GLPI locations - $params = [ - 'zone' => $zone, - ]; - - $base_path = GLPI_PLUGIN_DOC_DIR . '/carbon/carbon_intensity/' . $this->getSourceName() . '/' . $zone; + $is_free_plan = Config::getConfigurationValue('electricitymap_fake_data') ?? 0; + $dataset = $is_free_plan ? 'fake' : 'real'; + $zone = new Zone(); + $zone->getFromDBByCrit(['id' => $source_zone->fields[Zone::getForeignKeyField()]]); + $base_path = GLPI_PLUGIN_DOC_DIR . '/carbon/carbon_intensity/' . $this->getSourceName() . '/' . $dataset . '/' . $zone->fields['name']; $cache_file = $this->getCacheFilename( $base_path, $start, @@ -293,24 +295,47 @@ public function fetchRange(DateTimeImmutable $start, DateTimeImmutable $stop, st } // Set timezone to +00:00 and extend range by 12 hours on each side - $request_start = $start->setTimezone(new DateTimeZone('+0000'))->sub(new DateInterval('PT12H')); - $request_stop = $stop->setTimezone(new DateTimeZone('+0000'))->add(new DateInterval('PT12H')); + $timezone_z = new DateTimeZone('+0000'); + $request_start = $start->setTimezone($timezone_z)->sub(new DateInterval('PT12H')); + $request_stop = $stop->setTimezone($timezone_z)->add(new DateInterval('PT14H')); $this->step = 60; - $step = new DateInterval('P10D'); + $step = new DateInterval('PT240H'); $full_response = []; $current_date = DateTime::createFromImmutable($request_start); + $glpikey = new GLPIKey(); + $api_key = Config::getConfigurationValue('electricitymap_api_key'); + $api_key = $glpikey->decrypt($api_key); while ($current_date < $request_stop) { - $response = $this->client->request('GET', $this->base_url . self::PAST_URL, ['query' => $params]); + $stop = clone $current_date; + $stop->add($step); + // For some reason, passing the parameters as a query stringthrough Guzzle + // Makes the request malformed from the point of view of Electricitymaps + // Workarounded by building here the query string + // $params = [ + // 'zone' => $source_zone->fields['code'], + // 'start' => $current_date->format('Y-m-d\+H:i'), + // 'end' => $stop->format('Y-m-d\+H:i'), + // ]; + $url = $this->base_url . self::PAST_URL; + $url .= '?zone=' . $source_zone->fields['code']; + $url .= '&start=' . $current_date->format('Y-m-d\+H:i'); + $url .= '&end=' . $stop->format('Y-m-d\+H:i'); + $url .= '&temporalGranularity=' . 'hourly'; + $url .= '&emissionFactorType=' . 'lifecycle'; + $response = $this->client->request('GET', $url, [/*'query' => $params,*/ 'headers' => ['auth-token' => $api_key]]); + if (isset($response['status']) && $response['status'] === 'error') { + trigger_error('Electricity maps API error: ' . $response['message'], E_USER_ERROR); + } + if (isset($response['error'])) { + trigger_error('Electricity maps API error: ' . $response['error'], E_USER_ERROR); + } if (!$full_response) { $full_response = $response; } else { $full_response['data'] = array_merge($full_response['data'], $response['data']); } - $current_date->add($step); - if ($current_date > $request_stop) { - $current_date = $request_stop; - } + $current_date = min($request_stop, $stop); } if (!$full_response) { return []; @@ -325,8 +350,11 @@ public function fetchRange(DateTimeImmutable $start, DateTimeImmutable $stop, st return []; } - $json = json_encode($full_response); - file_put_contents($cache_file, $json); + $downloaded_year_month = $start->format('Y-m'); + if (count($full_response) > 0 && $downloaded_year_month < date('Y-m')) { + $json = json_encode($full_response); + file_put_contents($cache_file, $json); + } return $full_response['data']; } @@ -348,14 +376,11 @@ protected function formatOutput(array $response, int $step): array ]; } - return [ - 'source' => $this->getSourceName(), - $response['zone'] => $intensities, - ]; + return $intensities; } /** - * Try ti determine the data quality of record + * Try to determine the data quality of record * * @param array $record * @return int @@ -370,32 +395,34 @@ protected function getDataQuality(array $record): int return $data_quality; } - public function incrementalDownload(string $zone, DateTimeImmutable $start_date, CarbonIntensity $intensity, int $limit = 0): int + public function incrementalDownload(Source_Zone $source_zone, DateTimeImmutable $start_date, CarbonIntensity $intensity, int $limit = 0): int { $count = 0; $saved = 0; try { - $data = $this->fetchDay(new DateTimeImmutable(), $zone); + $data = $this->fetchDay(new DateTimeImmutable(), $source_zone); } catch (AbortException $e) { throw $e; } - $saved = $intensity->save($zone, $this->getSourceName(), $data[$zone]); + $zone = Zone::getById($source_zone->fields[Zone::getForeignKeyField()]); + $saved = $intensity->save($source_zone, $data[$zone->fields['name']]); $count += abs($saved); - if ($limit > 0 && $count >= $limit) { - return $saved > 0 ? $count : -$count; - } return $saved > 0 ? $count : -$count; } - public function fullDownload(string $zone, DateTimeImmutable $start_date, DateTimeImmutable $stop_date, CarbonIntensity $intensity, int $limit = 0, ?ProgressBar $progress = null): int + public function fullDownload(Source_Zone $source_zone, DateTimeImmutable $start_date, DateTimeImmutable $stop_date, CarbonIntensity $intensity, int $limit = 0, ?ProgressBar $progress = null): int { - // TODO : implement progress bar + $use_free_plan = (int) Config::getConfigurationValue('electricitymap_free_plan'); + if ($use_free_plan === 0) { + return parent::fullDownload($source_zone, $start_date, $stop_date, $intensity, $limit); + } + // Disable full download because we miss documentation for PAST_URL endpoint $start_date = new DateTime('24 hours ago'); $start_date->setTime((int) $start_date->format('H'), 0, 0); $start_date = DateTimeImmutable::createFromMutable($start_date); - return $this->incrementalDownload($zone, $start_date, $intensity, $limit); + return $this->incrementalDownload($source_zone, $start_date, $intensity, $limit); } protected function sliceDateRangeByDay(DateTimeImmutable $start, DateTimeImmutable $stop) diff --git a/src/DataSource/CarbonIntensity/ElectricityMaps/Config.php b/src/DataSource/CarbonIntensity/ElectricityMaps/Config.php index 6936a886..d35381d8 100644 --- a/src/DataSource/CarbonIntensity/ElectricityMaps/Config.php +++ b/src/DataSource/CarbonIntensity/ElectricityMaps/Config.php @@ -59,9 +59,21 @@ public function getConfigTemplate(): string {{ fields.passwordField( 'electricitymap_api_key', - current_config['electricitymap_api_key'], + '', __('Key for electricitymap.org API', 'carbon') ) }} + + {{ fields.checkboxField( + 'electricitymap_free_plan', + current_config['electricitymap_free_plan'] ?? 0, + __('Free plan', 'carbon') + ) }} + + {{ fields.checkboxField( + 'electricitymap_fake_data', + current_config['electricitymap_fake_data'] ?? 0, + __('API key is development key (fake data)', 'carbon') + ) }} TWIG; return $twig; @@ -69,10 +81,17 @@ public function getConfigTemplate(): string public function configUpdate(array $input): array { + $to_delete = []; foreach (self::getSecuredConfigs() as $field) { if (isset($input[$field]) && empty($input[$field])) { unset($input[$field]); } + if (($input['_blank_' . $field] ?? '') === 'on') { + $to_delete[] = $field; + } + } + if (count($to_delete)) { + PluginConfig::deletePluginConfigurationValues($to_delete); } return $input; diff --git a/src/DataSource/CarbonIntensity/Rte/Client.php b/src/DataSource/CarbonIntensity/Rte/Client.php index 76dc2696..505a5ecc 100644 --- a/src/DataSource/CarbonIntensity/Rte/Client.php +++ b/src/DataSource/CarbonIntensity/Rte/Client.php @@ -77,6 +77,8 @@ class Client extends AbstractClient /** @var float Tolerance ratio of unknown samples */ private const UNKNOWN_SAMPLES_WARNING = 0.05; + private const REQUEST_TIMEZONE = 'Europe/Paris'; + private RestApiClientInterface $client; private string $base_url; @@ -164,10 +166,10 @@ public function createZones(): int * Note that the HOUR-2 seems to be not fully guaranted. * * @param DateTimeImmutable $day date to download from [00::00:00 to 24:00:00[ - * @param string $zone + * @param Source_Zone $source_zone * @return array */ - public function fetchDay(DateTimeImmutable $day, string $zone): array + public function fetchDay(DateTimeImmutable $day, Source_Zone $source_zone): array { $stop = $day->add(new DateInterval('P1D')); @@ -175,10 +177,10 @@ public function fetchDay(DateTimeImmutable $day, string $zone): array $from = $day->format($format); $to = $stop->format($format); - $timezone = new DateTimeZone('Europe/Paris'); + $timezone = new DateTimeZone(self::REQUEST_TIMEZONE); $params = [ 'select' => 'date_heure,taux_co2', - 'where' => "date_heure IN [date'$from' TO date'$to'[ AND taux_co2 is not null", + 'where' => "date_heure IN [date'$from' TO date'$to'[", 'order_by' => 'date_heure asc', 'timezone' => $timezone->getName(), ]; @@ -224,14 +226,16 @@ public function fetchDay(DateTimeImmutable $day, string $zone): array * * @param DateTimeImmutable $start * @param DateTimeImmutable $stop - * @param string $zone + * @param Source_Zone $source_zone * @param int $dataset * @return array */ - public function fetchRange(DateTimeImmutable $start, DateTimeImmutable $stop, string $zone, int $dataset = self::DATASET_REALTIME): array + public function fetchRange(DateTimeImmutable $start, DateTimeImmutable $stop, Source_Zone $source_zone, int $dataset = self::DATASET_REALTIME): array { // Build realtime and consolidated paths - $base_path = GLPI_PLUGIN_DOC_DIR . '/carbon/carbon_intensity/' . $this->getSourceName() . '/' . $zone; + $zone = new Zone(); + $zone->getFromDBByCrit(['id' => $source_zone->fields[Zone::getForeignKeyField()]]); + $base_path = GLPI_PLUGIN_DOC_DIR . '/carbon/carbon_intensity/' . $this->getSourceName() . '/' . $zone->fields['name']; $consolidated_dir = $base_path . '/consolidated'; $realtime_dir = $base_path . '/realtime'; @@ -260,10 +264,12 @@ public function fetchRange(DateTimeImmutable $start, DateTimeImmutable $stop, st $cache_dir = $realtime_dir; break; } + $query_timezone = new DateTimeZone(self::REQUEST_TIMEZONE); $cache_file = $this->getCacheFilename( $cache_dir, $start, - $stop + $stop, + $query_timezone ); $url = $this->base_url . $url; @@ -285,30 +291,33 @@ public function fetchRange(DateTimeImmutable $start, DateTimeImmutable $stop, st // Prepare the HTTP request // Optimal timezone for returned dates to reduce DST mess in the response - $timezone = new DateTimeZone('Europe/Paris'); $where = "date_heure IN [date'$from' TO date'$to'["; $params = [ 'select' => 'date_heure,taux_co2', 'where' => $where, 'order_by' => 'date_heure asc', - 'timezone' => $timezone->getName(), + 'timezone' => $query_timezone->getName(), ]; try { $response = $this->client->request('GET', $url, ['timeout' => 8, 'query' => $params]); } catch (RuntimeException $e) { return []; } + if (count($response) === 0) { + return []; + } $this->step = $this->detectStep($response); $expected_samples_count = $expected_samples_hours * (60 / $this->step); $expected_samples_count--; // End boundary is excluded, decreasing the expeected count by 1 if (($dataset === self::DATASET_REALTIME && abs(count($response) - $expected_samples_count) > (60 / $this->step))) { - $alt_response = $this->fetchRange($start, $stop, $zone, self::DATASET_CONSOLIDATED); + $alt_response = $this->fetchRange($start, $stop, $source_zone, self::DATASET_CONSOLIDATED); if (!isset($alt_response['error_code']) && count($alt_response) > count($response)) { // Use the alternative response if more samples than the original response $response = $alt_response; } } else { - if (count($response) > 0 && $stop->format('Y-m') < date('Y-m')) { + $downloaded_year_month = $start->format('Y-m'); + if (count($response) > 0 && $downloaded_year_month < date('Y-m')) { // Cache only if the month being processed is older than the month of now $json = json_encode($response); file_put_contents($cache_file, $json); @@ -338,19 +347,16 @@ protected function formatOutput(array $response, int $step): array $intensities = $this->downsample($response, $this->step); } else { $intensities = []; - foreach ($response as $local_datetime => $record) { + array_walk($response, function ($record) use ($intensities) { $intensities[] = [ - 'datetime' => $record['date_heure']->format('Y-m-d\TH:00:00??????'), + 'datetime' => $record['datetime']->format('Y-m-d\TH:00:00??????'), 'intensity' => (float) $record['taux_co2'], 'data_quality' => AbstractTracked::DATA_QUALITY_RAW_REAL_TIME_MEASUREMENT, ]; - } + }); } - return [ - 'source' => self::getSourceName(), - 'France' => $intensities, - ]; + return $intensities; } /** @@ -550,9 +556,9 @@ protected function downsample(array $records, int $step): array */ private function switchToWinterTime(DateTime $previous, DateTime $date): bool { - $timezone_paris = new DateTimeZone('Europe/Paris'); - $previous->setTimezone($timezone_paris); - $date->setTimezone($timezone_paris); + $timezone = new DateTimeZone(self::REQUEST_TIMEZONE); + $previous->setTimezone($timezone); + $date->setTimezone($timezone); $first_dst = $previous->format('I'); $second_dst = $date->format('I'); return $first_dst === '1' && $second_dst === '0'; diff --git a/src/DataSource/RestApiClient.php b/src/DataSource/RestApiClient.php index 982a1fd7..ba49ca66 100644 --- a/src/DataSource/RestApiClient.php +++ b/src/DataSource/RestApiClient.php @@ -68,7 +68,8 @@ public function __construct(array $params = []) public function request(string $method = 'GET', string $uri = '', array $options = []) { try { - $response = $this->api_client->request($method, $uri, $options); + $request = $this->api_client; + $response = $request->request($method, $uri, $options); } catch (RequestException $e) { $this->last_error = [ 'title' => "Plugins API error", diff --git a/src/Source_Zone.php b/src/Source_Zone.php index 5abf6bd6..0ac86eb6 100644 --- a/src/Source_Zone.php +++ b/src/Source_Zone.php @@ -514,8 +514,7 @@ public function showGaps() $carbon_intensity = new CarbonIntensity(); $zone_id = $this->fields['plugin_carbon_zones_id']; $entries = $carbon_intensity->findGaps( - $this->fields['plugin_carbon_sources_id'], - $this->fields['plugin_carbon_zones_id'], + $this, $oldest_asset_date ); diff --git a/src/Toolbox.php b/src/Toolbox.php index dc21687a..a79d4e20 100644 --- a/src/Toolbox.php +++ b/src/Toolbox.php @@ -117,7 +117,7 @@ public function getOldestAssetDate(array $crit = []): ?DateTimeImmutable } /** - * Find the date where an asset leaves the inventory + * Find the date where assets leave the inventory * * @param array $crit * @return DateTimeImmutable|null diff --git a/tests/units/CarbonIntensityTest.php b/tests/units/CarbonIntensityTest.php index 1f39df68..4fe25834 100644 --- a/tests/units/CarbonIntensityTest.php +++ b/tests/units/CarbonIntensityTest.php @@ -49,25 +49,27 @@ #[CoversClass(CarbonIntensity::class)] class CarbonIntensityTest extends DbTestCase { - public function testGetLastKnownDate() + public function test_getLastKnownDate_returns_null_when_nothing_for_given_source_zone() { + $source = $this->createItem(Source::class, ['name' => 'foo']); + $zone = $this->createItem(Zone::class, ['name' => 'bar']); + $source_zone = $this->createItem(Source_Zone::class, [ + getForeignKeyFieldForItemType(Source::class) => $source->getID(), + getForeignKeyFieldForItemType(Zone::class) => $zone->getID(), + ]); $instance = new CarbonIntensity(); - $result = $instance->getLastKnownDate('foo', 'bar'); + $result = $instance->getLastKnownDate($source_zone); $this->assertNull($result); + } - $zone = $this->createItem(Zone::class, [ - 'name' => 'foo', - ]); - $source = $this->createItem(Source::class, [ - 'name' => 'bar', - ]); + public function test_getLastKnownDate_returns_date_when_carbon_intensity_exists_for_given_source_zone() + { + $source = $this->createItem(Source::class, ['name' => 'foo']); + $zone = $this->createItem(Zone::class, ['name' => 'bar']); $source_zone = $this->createItem(Source_Zone::class, [ - $source::getForeignKeyField() => $source->getID(), - $zone::getForeignKeyField() => $zone->getID(), + getForeignKeyFieldForItemType(Source::class) => $source->getID(), + getForeignKeyFieldForItemType(Zone::class) => $zone->getID(), ]); - $result = $instance->getLastKnownDate('foo', 'bar'); - $this->assertNull($result); - $intensity = $this->createItem(CarbonIntensity::class, [ 'date' => '2023-02-01 00:00:00', $source::getForeignKeyField() => $source->getID(), @@ -83,29 +85,33 @@ public function testGetLastKnownDate() 'intensity' => 255, 'data_quality' => 2, ]); - $result = $instance->getLastKnownDate('foo', 'bar'); + $instance = new CarbonIntensity(); + $result = $instance->getLastKnownDate($source_zone); $this->assertEquals($expected, $result->format('Y-m-d H:i:s')); } - public function testGetFirstKnownDate() + public function test_getFirstKnownDate_returns_null_when_no_carbon_intensity_for_given_source_zone() { + $source = $this->createItem(Source::class, ['name' => 'foo']); + $zone = $this->createItem(Zone::class, ['name' => 'bar']); + $source_zone = $this->createItem(Source_Zone::class, [ + getForeignKeyFieldForItemType(Source::class) => $source->getID(), + getForeignKeyFieldForItemType(Zone::class) => $zone->getID(), + ]); $instance = new CarbonIntensity(); - $result = $instance->getFirstKnownDate('foo', 'bar'); + $result = $instance->getFirstKnownDate($source_zone); $this->assertNull($result); + } - $zone = $this->createItem(Zone::class, [ - 'name' => 'foo', - ]); - $source = $this->createItem(Source::class, [ - 'name' => 'bar', - ]); + public function test_getFirstKnownDate_returns_date_when_carbon_intensity_exists_for_given_source_zone() + { + $source = $this->createItem(Source::class, ['name' => 'foo']); + $zone = $this->createItem(Zone::class, ['name' => 'bar']); $source_zone = $this->createItem(Source_Zone::class, [ - $source::getForeignKeyField() => $source->getID(), - $zone::getForeignKeyField() => $zone->getID(), + getForeignKeyFieldForItemType(Source::class) => $source->getID(), + getForeignKeyFieldForItemType(Zone::class) => $zone->getID(), ]); - $result = $instance->getFirstKnownDate('foo', 'bar'); - $this->assertNull($result); - + $instance = new CarbonIntensity(); $intensity = $this->createItem(CarbonIntensity::class, [ 'date' => '2025-02-01 00:00:00', $source::getForeignKeyField() => $source->getID(), @@ -121,7 +127,7 @@ public function testGetFirstKnownDate() 'intensity' => 255, 'data_quality' => 2, ]); - $result = $instance->getFirstKnownDate('foo', 'bar'); + $result = $instance->getFirstKnownDate($source_zone); $this->assertEquals($expected, $result->format('Y-m-d H:i:s')); } @@ -172,7 +178,7 @@ public function testFindGaps() } $carbon_intensity = new CarbonIntensity(); - $result = $carbon_intensity->findGaps($source->getID(), $zone->getID(), $start_date, $end_date); + $result = $carbon_intensity->findGaps($source_zone, $start_date, $end_date); $result = iterator_to_array($result); $this->assertEquals([], $result); @@ -184,7 +190,7 @@ public function testFindGaps() 'date' => ['<', $delete_before_date->format('Y-m-d H:i:s')], ]); - $result = $carbon_intensity->findGaps($source->getID(), $zone->getID(), $start_date, $end_date); + $result = $carbon_intensity->findGaps($source_zone, $start_date, $end_date); $result = iterator_to_array($result); $this->assertEquals([ [ @@ -200,7 +206,7 @@ public function testFindGaps() 'plugin_carbon_zones_id' => $zone->getID(), 'date' => ['>=', $delete_after_date->format('Y-m-d H:i:s')], ]); - $result = $carbon_intensity->findGaps($source->getID(), $zone->getID(), $start_date, $end_date); + $result = $carbon_intensity->findGaps($source_zone, $start_date, $end_date); $result = iterator_to_array($result); $this->assertEquals([ [ @@ -224,7 +230,7 @@ public function testFindGaps() ['date' => ['<', $delete_middle_end_date->format('Y-m-d H:i:s')]], ], ]); - $result = $carbon_intensity->findGaps($source->getID(), $zone->getID(), $start_date, $end_date); + $result = $carbon_intensity->findGaps($source_zone, $start_date, $end_date); $result = iterator_to_array($result); $this->assertEquals([ [ @@ -253,7 +259,7 @@ public function testFindGaps() $cursor_date->modify('+1 hour'); } - $result = $carbon_intensity->findGaps($source->getID(), $zone->getID(), $start_date, $end_date); + $result = $carbon_intensity->findGaps($source_zone, $start_date, $end_date); $result = iterator_to_array($result); $this->assertEquals([ [ @@ -278,7 +284,7 @@ public function testFindGaps() $cursor_date->modify('+1 hour'); } - $result = $carbon_intensity->findGaps($source->getID(), $zone->getID(), $start_date, $end_date); + $result = $carbon_intensity->findGaps($source_zone, $start_date, $end_date); $result = iterator_to_array($result); $this->assertEquals([ [ @@ -310,48 +316,44 @@ public function testGetDownloadStartDate() $this->assertEquals($expected, $result); } - public function testGetDownloadStopDate() - { - $instance = new CarbonIntensity(); - - $data_source = $this->getMockBuilder(AbstractClient::class) - ->getMock(); - $data_source->method('getSourceName')->willReturn('bar'); - $data_source->method('getMaxIncrementalAge')->willReturn( - DateTimeImmutable::createFromMutable($expected = (new DateTime('15 days ago'))->setTime(0, 0, 0)) - ); - - $result = $instance->getDownloadStopDate('foo', $data_source); - $this->assertEquals($expected, $result); - - $zone = $this->createItem(Zone::class, [ - 'name' => 'foo', - ]); - $source = $this->createItem(Source::class, [ - 'name' => 'bar', - ]); - $expected = new DateTimeImmutable('2019-01-31 23:00:00'); - $source_zone = $this->createItem(Source_Zone::class, [ - $source::getForeignKeyField() => $source->getID(), - $zone::getForeignKeyField() => $zone->getID(), - ]); - $intensity = $this->createItem(CarbonIntensity::class, [ - 'date' => '2019-02-01', - $source::getForeignKeyField() => $source->getID(), - $zone::getForeignKeyField() => $zone->getID(), - 'intensity' => 255, - 'data_quality' => 2, - ]); - $result = $instance->getDownloadStopDate('foo', $data_source); - $this->assertEquals($expected, $result); - } + // public function testGetDownloadStopDate() + // { + // $instance = new CarbonIntensity(); + + // $data_source = $this->getMockBuilder(AbstractClient::class) + // ->getMock(); + // $data_source->method('getSourceName')->willReturn('bar'); + // $data_source->method('getMaxIncrementalAge')->willReturn( + // DateTimeImmutable::createFromMutable($expected = (new DateTime('15 days ago'))->setTime(0, 0, 0)) + // ); + + // $result = $instance->getDownloadStopDate('foo', $data_source); + // $this->assertEquals($expected, $result); + + // $zone = $this->createItem(Zone::class, [ + // 'name' => 'foo', + // ]); + // $source = $this->createItem(Source::class, [ + // 'name' => 'bar', + // ]); + // $expected = new DateTimeImmutable('2019-01-31 23:00:00'); + // $source_zone = $this->createItem(Source_Zone::class, [ + // $source::getForeignKeyField() => $source->getID(), + // $zone::getForeignKeyField() => $zone->getID(), + // ]); + // $intensity = $this->createItem(CarbonIntensity::class, [ + // 'date' => '2019-02-01', + // $source::getForeignKeyField() => $source->getID(), + // $zone::getForeignKeyField() => $zone->getID(), + // 'intensity' => 255, + // 'data_quality' => 2, + // ]); + // $result = $instance->getDownloadStopDate('foo', $data_source); + // $this->assertEquals($expected, $result); + // } public function testDownloadOneZone() { - $instance = new CarbonIntensity(); - $result = $instance->getLastKnownDate('foo', 'bar'); - $this->assertNull($result); - $zone = $this->createItem(Zone::class, [ 'name' => 'foo', ]); @@ -377,7 +379,8 @@ function ($zone_name, $gap_start, $gap_end, $carbon_intensity, $limit, $progress ->getMock(); $progress_bar = new ProgressBar($output); - $result = $instance->downloadOneZone($data_source, 'foo', 1, $progress_bar); + $instance = new CarbonIntensity(); + $result = $instance->downloadOneZone($data_source, $source_zone, 1, $progress_bar); $this->assertEquals($hours, $result); $this->assertEquals($hours, $progress_bar->getMaxSteps()); } diff --git a/tests/units/CronTaskTest.php b/tests/units/CronTaskTest.php index 0ff01047..a612ffaa 100644 --- a/tests/units/CronTaskTest.php +++ b/tests/units/CronTaskTest.php @@ -44,56 +44,87 @@ use GlpiPlugin\Carbon\CarbonIntensity; use GlpiPlugin\Carbon\CronTask; use GlpiPlugin\Carbon\DataSource\CarbonIntensity\ClientInterface; +use GlpiPlugin\Carbon\Source; +use GlpiPlugin\Carbon\Source_Zone; +use GlpiPlugin\Carbon\Zone; use Location as GlpiLocation; use PHPUnit\Framework\Attributes\CoversClass; #[CoversClass(CronTask::class)] class CronTaskTest extends DbTestCase { - public function downloadSourceProvider() + public function test_downloadCarbonIntensityFromSource_returns_0_when_no_data_is_downloaded() { - $data_source1 = $this->createStub(ClientInterface::class); - $data_source1->method('getZones')->willReturn([['name' => 'test_zone']]); - $intensity1 = $this->createStub(CarbonIntensity::class); - $intensity1->method('downloadOneZone')->willReturn(0); - yield 'download empty data' => [ - $data_source1, - $intensity1, - 0, - ]; - - $data_source2 = $this->createStub(ClientInterface::class); - $data_source2->method('getZones')->willReturn([['name' => 'test_zone']]); - $intensity2 = $this->createStub(CarbonIntensity::class); - $intensity2->method('downloadOneZone')->willReturn(1024); - yield 'download complete' => [ - $data_source2, - $intensity2, - 1, - ]; - - $data_source3 = $this->createStub(ClientInterface::class); - $data_source3->method('getZones')->willReturn([['name' => 'test_zone']]); - $intensity3 = $this->createStub(CarbonIntensity::class); - $intensity3->method('downloadOneZone')->willReturn(-5); - yield 'download incomplete' => [ - $data_source3, - $intensity3, - -1, - ]; + $source = $this->createItem(Source::class); + $zone = $this->createItem(Zone::class); + $source_zone = $this->createItem(Source_Zone::class, [ + $source->getForeignKeyField() => $source->getID(), + $zone->getForeignKeyField() => $zone->getID(), + 'code' => 'FOO', + 'is_download_enabled' => 1, + ]); + + $data_source = $this->createStub(ClientInterface::class); + $data_source->method('getZones')->willReturn([['name' => 'FOO']]); + $data_source->method('isZoneSetupComplete')->willReturn(true); + $data_source->method('getSourceZones')->willReturn([$source_zone->fields]); + $carbon_intensity = $this->createStub(CarbonIntensity::class); + $carbon_intensity->method('downloadOneZone')->willReturn(0); + $cron_task = new CronTask(); + $glpi_cron_task = new GlpiCronTask(); + $glpi_cron_task->fields['param'] = 1000; + $output = $cron_task->downloadCarbonIntensityFromSource($glpi_cron_task, $data_source, $carbon_intensity); + $this->assertEquals(0, $output); } - public function testDownloadCarbonIntensityFromSource() + public function test_downloadCarbonIntensityFromSource_returns_1_when_a_positive_count_of_samples_are_downloaded() { - foreach ($this->downloadSourceProvider() as $data) { - [$data_source, $intensity, $expected] = $data; - $cron_task = new CronTask(); - $glpi_cron_task = new GlpiCronTask(); - $glpi_cron_task->fields['param'] = 1000; - $output = $this->callPrivateMethod($cron_task, 'downloadCarbonIntensityFromSource', $glpi_cron_task, $data_source, $intensity); - - $this->assertEquals($expected, $output); - } + $source = $this->createItem(Source::class); + $zone = $this->createItem(Zone::class); + $source_zone = $this->createItem(Source_Zone::class, [ + $source->getForeignKeyField() => $source->getID(), + $zone->getForeignKeyField() => $zone->getID(), + 'code' => 'FOO', + 'is_download_enabled' => 1, + ]); + + $data_source = $this->createStub(ClientInterface::class); + $data_source->method('getZones')->willReturn([['name' => 'FOO']]); + $data_source->method('isZoneSetupComplete')->willReturn(true); + $data_source->method('getSourceZones')->willReturn([$source_zone->fields]); + $carbon_intensity = $this->createStub(CarbonIntensity::class); + $carbon_intensity->method('downloadOneZone')->willReturn(1024); + $cron_task = new CronTask(); + $glpi_cron_task = new GlpiCronTask(); + $glpi_cron_task->fields['param'] = 1000; + $output = $cron_task->downloadCarbonIntensityFromSource($glpi_cron_task, $data_source, $carbon_intensity); + $this->assertEquals(1, $output); + } + + public function test_downloadCarbonIntensityFromSource_returns_minus_1_when_a_negative_count_of_samples_are_downloaded() + { + // When the count of downloaded samples is negative the count is this absolute value, + // and the negative sign means that an error occurred + $source = $this->createItem(Source::class); + $zone = $this->createItem(Zone::class); + $source_zone = $this->createItem(Source_Zone::class, [ + $source->getForeignKeyField() => $source->getID(), + $zone->getForeignKeyField() => $zone->getID(), + 'code' => 'FOO', + 'is_download_enabled' => 1, + ]); + + $data_source = $this->createStub(ClientInterface::class); + $data_source->method('getZones')->willReturn([['name' => 'FOO']]); + $data_source->method('isZoneSetupComplete')->willReturn(true); + $data_source->method('getSourceZones')->willReturn([$source_zone->fields]); + $carbon_intensity = $this->createStub(CarbonIntensity::class); + $carbon_intensity->method('downloadOneZone')->willReturn(-5); + $cron_task = new CronTask(); + $glpi_cron_task = new GlpiCronTask(); + $glpi_cron_task->fields['param'] = 1000; + $output = $cron_task->downloadCarbonIntensityFromSource($glpi_cron_task, $data_source, $carbon_intensity); + $this->assertEquals(-1, $output); } public function testFillIncompleteLocations() diff --git a/tests/units/DataSource/CarbonIntensity/ElectricityMaps/ClientTest.php b/tests/units/DataSource/CarbonIntensity/ElectricityMaps/ClientTest.php index f766d367..2c79a293 100644 --- a/tests/units/DataSource/CarbonIntensity/ElectricityMaps/ClientTest.php +++ b/tests/units/DataSource/CarbonIntensity/ElectricityMaps/ClientTest.php @@ -80,7 +80,7 @@ public function testFetchDay() ]); $date = new DateTimeImmutable('5 days ago'); - $intensities = $data_source->fetchDay($date, 'France'); + $intensities = $data_source->fetchDay($date, $source_zone); $this->assertIsArray($intensities); $this->assertArrayHasKey('source', $intensities); @@ -153,7 +153,7 @@ public function testFetchRange() $intensities = $data_source->fetchRange( DateTimeImmutable::createFromMutable($start), DateTimeImmutable::createFromMutable($stop), - 'France' + $source_zone ); $this->assertCount(24 * 30 + 2 * 12, $intensities); diff --git a/tests/units/DataSource/CarbonIntensity/Rte/ClientTest.php b/tests/units/DataSource/CarbonIntensity/Rte/ClientTest.php index 65ee2f03..477e9c64 100644 --- a/tests/units/DataSource/CarbonIntensity/Rte/ClientTest.php +++ b/tests/units/DataSource/CarbonIntensity/Rte/ClientTest.php @@ -36,6 +36,8 @@ use DateTimeImmutable; use GlpiPlugin\Carbon\CarbonIntensity; use GlpiPlugin\Carbon\DataSource\RestApiClientInterface; +use GlpiPlugin\Carbon\Source; +use GlpiPlugin\Carbon\Source_Zone; use GlpiPlugin\Carbon\Tests\DbTestCase; use GlpiPlugin\Carbon\Zone; use PHPUnit\Framework\Attributes\CoversClass; @@ -45,6 +47,13 @@ class ClientTest extends DbTestCase { public function testFetchDay() { + $source = $this->createItem(Source::class, ['name' => 'foo']); + $zone = $this->createItem(Zone::class, ['name' => 'bar']); + $source_zone = $this->createItem(Source_Zone::class, [ + getForeignKeyFieldForItemType(Source::class) => $source->getID(), + getForeignKeyFieldForItemType(Zone::class) => $zone->getID(), + ]); + $client = $this->createStub(RestApiClientInterface::class); $fixture_file = TU_FIXTURE_PATH . '/RTE/api-sample.json'; $response = file_get_contents($fixture_file); @@ -53,7 +62,7 @@ public function testFetchDay() $source = new Client($client); $date = new DateTimeImmutable('5 days ago'); - $intensities = $source->fetchDay($date, ''); + $intensities = $source->fetchDay($date, $source_zone); $this->assertIsArray($intensities); $this->assertEquals(96, count($intensities)); @@ -61,6 +70,13 @@ public function testFetchDay() public function testFetchRange() { + $source = $this->createItem(Source::class, ['name' => 'foo']); + $zone = $this->createItem(Zone::class, ['name' => 'bar']); + $source_zone = $this->createItem(Source_Zone::class, [ + getForeignKeyFieldForItemType(Source::class) => $source->getID(), + getForeignKeyFieldForItemType(Zone::class) => $zone->getID(), + ]); + $client = $this->createStub(RestApiClientInterface::class); $response = []; $date = new DateTime('2021-03-01 00:00:00'); @@ -78,7 +94,7 @@ public function testFetchRange() $start = new DateTimeImmutable('2021-03-01'); $stop = new DateTimeImmutable('2021-03-27'); - $intensities = $source->fetchRange($start, $stop, ''); + $intensities = $source->fetchRange($start, $stop, $source_zone); $this->assertIsArray($intensities); $this->assertIsArray($intensities); // There are 2496 intensities in the sample set @@ -87,6 +103,12 @@ public function testFetchRange() public function testFullDownload() { + $source = $this->getItem(Source::class, ['name' => 'RTE']); + $zone = $this->getItem(Zone::class, ['name' => 'France']); + $source_zone = $this->getItem(Source_Zone::class, [ + getForeignKeyFieldForItemType(Source::class) => $source->getID(), + getForeignKeyFieldForItemType(Zone::class) => $zone->getID(), + ]); $client = $this->createStub(RestApiClientInterface::class); $client->method('request')->willReturn([ [ @@ -111,17 +133,18 @@ public function testFullDownload() $start_date = new DateTimeImmutable('2024-10-08'); $stop_date = new DateTimeImmutable('2024-10-09'); $carbon_intensity = new CarbonIntensity(); - $output = $instance->fullDownload('France', $start_date, $stop_date, $carbon_intensity); + $output = $instance->fullDownload($source_zone, $start_date, $stop_date, $carbon_intensity); $this->assertEquals(1, $output); } public function testIncrementalDownload() { - $zone = new Zone(); - $zone->getFromDBByCrit(['name' => 'France']); - if ($zone->isNewItem()) { - $zone = $this->createItem(Zone::class, ['name' => 'France']); - } + $source = $this->getItem(Source::class, ['name' => 'RTE']); + $zone = $this->getItem(Zone::class, ['name' => 'France']); + $source_zone = $this->getItem(Source_Zone::class, [ + getForeignKeyFieldForItemType(Source::class) => $source->getID(), + getForeignKeyFieldForItemType(Zone::class) => $zone->getID(), + ]); $intensity = $this->createMock(CarbonIntensity::class); // 4 calls to fetchRange [3 days ago; today] @@ -142,6 +165,6 @@ public function testIncrementalDownload() $start_date = new DateTime('3 days ago'); $start_date->setTime(0, 0, 0); $start_date = DateTimeImmutable::createFromMutable($start_date); - $instance->incrementalDownload('France', $start_date, $intensity); + $instance->incrementalDownload($source_zone, $start_date, $intensity); } } From de9c3e315314db7054b3534629dc0fdba817113f Mon Sep 17 00:00:00 2001 From: Thierry Bugier Date: Tue, 7 Apr 2026 14:21:59 +0200 Subject: [PATCH 4/4] fix(Datasource\CarbonIntensity\ElectricityMaps): factorize and fix date management --- install/install/create_automatic_actions.php | 30 ++--------- .../06_migrate_automatic_actions.php | 2 + src/CarbonIntensity.php | 4 +- src/Command/CollectCarbonIntensityCommand.php | 1 - src/DataSource/AbstractCronTask.php | 24 +++++++++ .../CarbonIntensity/AbstractClient.php | 2 + .../ElectricityMaps/Client.php | 52 +++++++++++++++---- .../ElectricityMaps/CronTask.php | 27 ++-------- src/DataSource/CarbonIntensity/Rte/Client.php | 2 +- .../CarbonIntensity/Rte/CronTask.php | 27 ++-------- tests/install/PluginInstallTest.php | 4 +- tests/units/CarbonIntensityTest.php | 4 ++ 12 files changed, 94 insertions(+), 85 deletions(-) diff --git a/install/install/create_automatic_actions.php b/install/install/create_automatic_actions.php index eacab97d..edef8bb2 100644 --- a/install/install/create_automatic_actions.php +++ b/install/install/create_automatic_actions.php @@ -65,30 +65,6 @@ 'param' => 10000, // Maximum rows to generate per execution ], ], - // [ - // 'itemtype' => CronTask::class, - // 'name' => 'DownloadRte', - // 'frequency' => DAY_TIMESTAMP, - // 'options' => [ - // 'mode' => GlpiCronTask::MODE_EXTERNAL, - // 'allowmode' => GlpiCronTask::MODE_INTERNAL + GlpiCronTask::MODE_EXTERNAL, - // 'logs_lifetime' => 30, - // 'comment' => __('Collect carbon intensities from RTE', 'carbon'), - // 'param' => 10000, // Maximum rows to generate per execution - // ] - // ], - // [ - // 'itemtype' => CronTask::class, - // 'name' => 'DownloadElectricityMap', - // 'frequency' => DAY_TIMESTAMP / 2, // Twice a day - // 'options' => [ - // 'mode' => GlpiCronTask::MODE_EXTERNAL, - // 'allowmode' => GlpiCronTask::MODE_INTERNAL + GlpiCronTask::MODE_EXTERNAL, - // 'logs_lifetime' => 30, - // 'comment' => __('Collect carbon intensities from ElectricityMap', 'carbon'), - // 'param' => 10000, // Maximum rows to generate per execution - // ] - // ], [ 'itemtype' => CronTask::class, 'name' => 'EmbodiedImpact', @@ -105,7 +81,11 @@ foreach ($automatic_actions as $action) { $task = new GlpiCronTask(); - if ($task->getFromDBByCrit(['name' => $action['name']]) !== false) { + $crit = [ + 'itemtype' => $action['itemtype'], + 'name' => $action['name'], + ]; + if ($task->getFromDBByCrit($crit) !== false) { $task->delete(['id' => $task->getID()]); } $success = GlpiCronTask::Register( diff --git a/install/migration/update_1.1.1_to_1.2.0/06_migrate_automatic_actions.php b/install/migration/update_1.1.1_to_1.2.0/06_migrate_automatic_actions.php index fb827e99..f3895e51 100644 --- a/install/migration/update_1.1.1_to_1.2.0/06_migrate_automatic_actions.php +++ b/install/migration/update_1.1.1_to_1.2.0/06_migrate_automatic_actions.php @@ -41,6 +41,7 @@ $task->update([ 'id' => $task->getID(), 'itemtype' => 'GlpiPlugin\\Carbon\\DataSource\\CarbonIntensity\\Rte\\CronTask', + 'name' => 'Download', ]); } @@ -53,5 +54,6 @@ $task->update([ 'id' => $task->getID(), 'itemtype' => 'GlpiPlugin\\Carbon\\DataSource\\CarbonIntensity\\ElectricityMaps\\CronTask', + 'name' => 'Download', ]); } diff --git a/src/CarbonIntensity.php b/src/CarbonIntensity.php index cfc706a7..8d7a8e35 100644 --- a/src/CarbonIntensity.php +++ b/src/CarbonIntensity.php @@ -300,11 +300,11 @@ public function getDownloadStartDate(): ?DateTimeImmutable * Save in database the carbon intensities * Give up on failures * - * @param Source_zone $source_zone Source_zone + * @param Source_Zone $source_zone Source_zone * @param array $data as an array of arrays ['datetime' => string, 'intensity' => float] * @return int count of actually saved items, */ - public function save(Source_zone $source_zone, array $data): int + public function save(Source_Zone $source_zone, array $data): int { /** @var DBmysql $DB */ global $DB; diff --git a/src/Command/CollectCarbonIntensityCommand.php b/src/Command/CollectCarbonIntensityCommand.php index 23453cdc..12ece0d8 100644 --- a/src/Command/CollectCarbonIntensityCommand.php +++ b/src/Command/CollectCarbonIntensityCommand.php @@ -58,7 +58,6 @@ class CollectCarbonIntensityCommand extends AbstractCommand { /** @var int ID of the data source being processed */ - private int $source_id; /** @var Source_Zone The relatin between a source and a zone to describe which data to download and save */ private Source_Zone $source_zone; private ?ClientInterface $client = null; diff --git a/src/DataSource/AbstractCronTask.php b/src/DataSource/AbstractCronTask.php index b0d9f3af..2f56723c 100644 --- a/src/DataSource/AbstractCronTask.php +++ b/src/DataSource/AbstractCronTask.php @@ -32,12 +32,36 @@ namespace GlpiPlugin\Carbon\DataSource; +use CommonDBTM; use CommonGLPI; +use GlpiPlugin\Carbon\DataSource\CarbonIntensity\ClientFactory; +use GlpiPlugin\Carbon\Source_Zone; abstract class AbstractCronTask extends CommonGLPI implements CronTaskInterface { + protected static string $client_name; + public function getTabNameForItem(CommonGLPI $item, $withtemplate = 0) { return ''; } + + public function showForCronTask(CommonDBTM $item) + { + switch ($item->fields['name']) { + case 'Download': + $client = ClientFactory::create(static::$client_name); + $source_name = $client->getSourceName(); + foreach ($client->getSupportedZones() as $zone_name) { + $source_zone = new Source_Zone(); + if (!$source_zone->getFromDbBySourceAndZone($source_name, $zone_name)) { + continue; + } + if (!$source_zone->fields['is_download_enabled']) { + continue; + } + $source_zone->showGaps(); + } + } + } } diff --git a/src/DataSource/CarbonIntensity/AbstractClient.php b/src/DataSource/CarbonIntensity/AbstractClient.php index fcc5829b..1e545e9c 100644 --- a/src/DataSource/CarbonIntensity/AbstractClient.php +++ b/src/DataSource/CarbonIntensity/AbstractClient.php @@ -58,6 +58,8 @@ abstract public function getDataInterval(): string; abstract protected function formatOutput(array $response, int $step): array; + abstract public function getHardStartDate(): DateTimeImmutable; + /** * Download all data for a single day from the datasource * diff --git a/src/DataSource/CarbonIntensity/ElectricityMaps/Client.php b/src/DataSource/CarbonIntensity/ElectricityMaps/Client.php index a70d1dd1..5650393c 100644 --- a/src/DataSource/CarbonIntensity/ElectricityMaps/Client.php +++ b/src/DataSource/CarbonIntensity/ElectricityMaps/Client.php @@ -37,6 +37,7 @@ use DateTimeImmutable; use DateTimeInterface; use DateTimeZone; +use DBmysql; use GLPIKey; use GlpiPlugin\Carbon\CarbonIntensity; use GlpiPlugin\Carbon\DataSource\CarbonIntensity\AbortException; @@ -269,6 +270,7 @@ public function fetchDay(DateTimeImmutable $day, Source_Zone $source_zone): arra public function fetchRange(DateTimeImmutable $start, DateTimeImmutable $stop, Source_Zone $source_zone): array { + $this->step = 60; $is_free_plan = Config::getConfigurationValue('electricitymap_fake_data') ?? 0; $dataset = $is_free_plan ? 'fake' : 'real'; $zone = new Zone(); @@ -277,7 +279,8 @@ public function fetchRange(DateTimeImmutable $start, DateTimeImmutable $stop, So $cache_file = $this->getCacheFilename( $base_path, $start, - $stop + $stop, + $start->getTimezone() ); // If cached file exists, use it if (file_exists($cache_file)) { @@ -306,6 +309,7 @@ public function fetchRange(DateTimeImmutable $start, DateTimeImmutable $stop, So $glpikey = new GLPIKey(); $api_key = Config::getConfigurationValue('electricitymap_api_key'); $api_key = $glpikey->decrypt($api_key); + $format = 'Y-m-d\+H:ip'; while ($current_date < $request_stop) { $stop = clone $current_date; $stop->add($step); @@ -319,8 +323,8 @@ public function fetchRange(DateTimeImmutable $start, DateTimeImmutable $stop, So // ]; $url = $this->base_url . self::PAST_URL; $url .= '?zone=' . $source_zone->fields['code']; - $url .= '&start=' . $current_date->format('Y-m-d\+H:i'); - $url .= '&end=' . $stop->format('Y-m-d\+H:i'); + $url .= '&start=' . $current_date->format($format); + $url .= '&end=' . $stop->format($format); $url .= '&temporalGranularity=' . 'hourly'; $url .= '&emissionFactorType=' . 'lifecycle'; $response = $this->client->request('GET', $url, [/*'query' => $params,*/ 'headers' => ['auth-token' => $api_key]]); @@ -360,10 +364,14 @@ public function fetchRange(DateTimeImmutable $start, DateTimeImmutable $stop, So protected function formatOutput(array $response, int $step): array { + // Convert string dates into datetime objects, + // using timezone expressed as type Continent/City instead of offset + // This is needed to detect later the switching to winter time + $response = $this->shiftToLocalTimezone($response); $intensities = []; $timezone = new DateTimeZone('UTC'); - foreach ($response['history'] as $record) { - $datetime = DateTime::createFromFormat('Y-m-d\TH:i:s+', $record['datetime'], $timezone); + foreach ($response['data'] as $record) { + $datetime = $record['datetime']; if (!$datetime instanceof DateTimeInterface) { var_dump(DateTime::getLastErrors()); continue; @@ -379,18 +387,42 @@ protected function formatOutput(array $response, int $step): array return $intensities; } + /** + * convert dates to the timezone of GLPI + * + * @param array $response + * @return array array of records: ['date_heure' => string, 'taux_co2' => number, 'datetime' => DateTime] + */ + protected function shiftToLocalTimezone(array $response): array + { + /** @var DBmysql $DB */ + global $DB; + + $shifted_response = []; + $local_timezone = new DateTimeZone($DB->guessTimezone()); + array_walk($response['data'], function ($item, $key) use (&$shifted_response, $local_timezone) { + $shifted_date_object = DateTime::createFromFormat('Y-m-d\TH:i:s.vp', $item['datetime']) + ->setTimezone($local_timezone); + $shifted_date_string = $shifted_date_object->format('Y-m-d H:i:sP'); + if (isset($shifted_response[$shifted_date_string]) && $shifted_response['carbonIntensity'] !== $item['carbonIntensity']) { + trigger_error("Duplicate record with different carbon intensity detected."); + } + $item['datetime'] = $shifted_date_object; + $shifted_response[$shifted_date_string] = $item; + }); + + return ['zone' => $response['zone'], 'data' => $shifted_response]; + } + /** * Try to determine the data quality of record * * @param array $record - * @return int + * @return int see AbstractTracked::DATA_QUALITY_* constants */ protected function getDataQuality(array $record): int { - $data_quality = 0; - if (!$record['isEstimated']) { - $data_quality = AbstractTracked::DATA_QUALITY_RAW_REAL_TIME_MEASUREMENT; - } + $data_quality = $record['isEstimated'] ? AbstractTracked::DATA_QUALITY_ESTIMATED : AbstractTracked::DATA_QUALITY_RAW_REAL_TIME_MEASUREMENT; return $data_quality; } diff --git a/src/DataSource/CarbonIntensity/ElectricityMaps/CronTask.php b/src/DataSource/CarbonIntensity/ElectricityMaps/CronTask.php index 02ae7461..9f23138d 100644 --- a/src/DataSource/CarbonIntensity/ElectricityMaps/CronTask.php +++ b/src/DataSource/CarbonIntensity/ElectricityMaps/CronTask.php @@ -32,7 +32,6 @@ namespace GlpiPlugin\Carbon\DataSource\CarbonIntensity\ElectricityMaps; -use CommonDBTM; use CommonGLPI; use CronTask as GlpiCronTask; use GlpiPlugin\Carbon\CarbonIntensity; @@ -40,11 +39,11 @@ use GlpiPlugin\Carbon\DataSource\AbstractCronTask; use GlpiPlugin\Carbon\DataSource\CarbonIntensity\ClientFactory; use GlpiPlugin\Carbon\DataSource\CronTaskInterface; -use GlpiPlugin\Carbon\DataSource\RestApiClient; -use GlpiPlugin\Carbon\Source_Zone; class CronTask extends AbstractCronTask implements CronTaskInterface { + protected static string $client_name = 'ElectricityMaps'; + public static function getIcon() { return 'fa-solid fa-gears'; @@ -61,7 +60,7 @@ public static function enumerateTasks(): array return [ [ 'itemtype' => self::class, - 'name' => 'DownloadElectricityMap', + 'name' => 'Download', 'frequency' => DAY_TIMESTAMP / 2, 'options' => [ 'mode' => GlpiCronTask::MODE_EXTERNAL, @@ -83,7 +82,7 @@ public static function enumerateTasks(): array public static function cronInfo(string $name): array { switch ($name) { - case 'DownloadElectricityMap': + case 'Download': return [ 'description' => __('Download carbon emissions from Electricity Maps', 'carbon'), 'parameter' => __('Maximum number of entries to download', 'carbon'), @@ -99,23 +98,7 @@ public static function cronInfo(string $name): array */ public static function cronDownloadElectricityMap(GlpiCronTask $task): int { - $client = ClientFactory::create('ElectricityMaps'); + $client = ClientFactory::create(static::$client_name); return CarbonCronTask::downloadCarbonIntensityFromSource($task, $client, new CarbonIntensity()); } - - public function showForCronTask(CommonDBTM $item) - { - switch ($item->fields['name']) { - case 'DownloadElectricityMap': - $client = new Client(new RestApiClient()); - $source_name = ($client)->getSourceName(); - foreach ($client->getSupportedZones() as $zone_name) { - $source_zone = new Source_Zone(); - if (!$source_zone->getFromDbBySourceAndZone($source_name, $zone_name)) { - continue; - } - $source_zone->showGaps(); - } - } - } } diff --git a/src/DataSource/CarbonIntensity/Rte/Client.php b/src/DataSource/CarbonIntensity/Rte/Client.php index 505a5ecc..8525a8a2 100644 --- a/src/DataSource/CarbonIntensity/Rte/Client.php +++ b/src/DataSource/CarbonIntensity/Rte/Client.php @@ -317,7 +317,7 @@ public function fetchRange(DateTimeImmutable $start, DateTimeImmutable $stop, So } } else { $downloaded_year_month = $start->format('Y-m'); - if (count($response) > 0 && $downloaded_year_month < date('Y-m')) { + if ($downloaded_year_month < date('Y-m')) { // Cache only if the month being processed is older than the month of now $json = json_encode($response); file_put_contents($cache_file, $json); diff --git a/src/DataSource/CarbonIntensity/Rte/CronTask.php b/src/DataSource/CarbonIntensity/Rte/CronTask.php index c46e6eb2..34d8eb73 100644 --- a/src/DataSource/CarbonIntensity/Rte/CronTask.php +++ b/src/DataSource/CarbonIntensity/Rte/CronTask.php @@ -32,7 +32,6 @@ namespace GlpiPlugin\Carbon\DataSource\CarbonIntensity\Rte; -use CommonDBTM; use CommonGLPI; use CronTask as GlpiCronTask; use GlpiPlugin\Carbon\CarbonIntensity; @@ -40,11 +39,11 @@ use GlpiPlugin\Carbon\DataSource\AbstractCronTask; use GlpiPlugin\Carbon\DataSource\CarbonIntensity\ClientFactory; use GlpiPlugin\Carbon\DataSource\CronTaskInterface; -use GlpiPlugin\Carbon\DataSource\RestApiClient; -use GlpiPlugin\Carbon\Source_Zone; class CronTask extends AbstractCronTask implements CronTaskInterface { + protected static string $client_name = 'Rte'; + public static function getIcon() { return 'fa-solid fa-gears'; @@ -55,29 +54,13 @@ public function getTabNameForItem(CommonGLPI $item, $withtemplate = 0) return self::createTabEntry(__('Resource diagnosis', 'carbon'), 0); } - public function showForCronTask(CommonDBTM $item) - { - switch ($item->fields['name']) { - case 'DownloadRte': - $client = new Client(new RestApiClient()); - $source_name = ($client)->getSourceName(); - foreach ($client->getSupportedZones() as $zone_name) { - $source_zone = new Source_Zone(); - if (!$source_zone->getFromDbBySourceAndZone($source_name, $zone_name)) { - continue; - } - $source_zone->showGaps(); - } - } - } - public static function enumerateTasks(): array { // TODO: This data shoud replace the occurrence in CronTask::cronInfo() return [ [ 'itemtype' => self::class, - 'name' => 'DownloadRte', + 'name' => 'Download', 'frequency' => DAY_TIMESTAMP, 'options' => [ 'mode' => GlpiCronTask::MODE_EXTERNAL, @@ -99,7 +82,7 @@ public static function enumerateTasks(): array public static function cronInfo(string $name): array { switch ($name) { - case 'DownloadRte': + case 'Download': return [ 'description' => __('Download carbon emissions from RTE', 'carbon'), 'parameter' => __('Maximum number of entries to download', 'carbon'), @@ -115,7 +98,7 @@ public static function cronInfo(string $name): array */ public static function cronDownloadRte(GlpiCronTask $task): int { - $client = ClientFactory::create('Rte'); + $client = ClientFactory::create(static::$client_name); return CarbonCronTask::downloadCarbonIntensityFromSource($task, $client, new CarbonIntensity()); } } diff --git a/tests/install/PluginInstallTest.php b/tests/install/PluginInstallTest.php index cde3af15..0d808153 100644 --- a/tests/install/PluginInstallTest.php +++ b/tests/install/PluginInstallTest.php @@ -257,14 +257,14 @@ private function checkAutomaticAction() $cronTask = new GLPICronTask(); $cronTask->getFromDBByCrit([ 'itemtype' => RteCronTask::class, - 'name' => 'DownloadRte', + 'name' => 'Download', ]); $this->assertFalse($cronTask->isNewItem()); $cronTask = new GLPICronTask(); $cronTask->getFromDBByCrit([ 'itemtype' => ElectricityMapsCronTask::class, - 'name' => 'DownloadElectricityMap', + 'name' => 'Download', ]); $this->assertFalse($cronTask->isNewItem()); diff --git a/tests/units/CarbonIntensityTest.php b/tests/units/CarbonIntensityTest.php index 4fe25834..932014d5 100644 --- a/tests/units/CarbonIntensityTest.php +++ b/tests/units/CarbonIntensityTest.php @@ -35,6 +35,7 @@ use Computer; use DateTime; use DateTimeImmutable; +use DateTimeInterface; use DBmysql; use GlpiPlugin\Carbon\CarbonIntensity; use GlpiPlugin\Carbon\DataSource\CarbonIntensity\AbstractClient; @@ -375,6 +376,9 @@ function ($zone_name, $gap_start, $gap_end, $carbon_intensity, $limit, $progress return $hours; } ); + $data_source->method('getHardStartDate')->willReturn( + DateTimeImmutable::createFromFormat(DateTimeInterface::ATOM, '2021-01-01T00:00:00+00:00'), + ); $output = $this->getMockBuilder(Output::class) ->getMock(); $progress_bar = new ProgressBar($output);