5454 * @see https://help.opendatasoft.com/apis/ods-explore-v2/explore_v2.1.html
5555 *
5656 * About DST for this data source
57- * GLPI must be set up with timezones enabled, set to the same timezone as the host system
58- *
57+ * GLPI must be set up with timezones enabled
5958 * If this requirement is not met, then dates here DST change occurs will cause problems
60- * Searching for gaps will find gaps that the algorithm will try to fill, but fail.
59+ * Searching for gaps will find gaps that the algorithm will try to fill, but fail repeatidly.
60+ *
61+ * Queries to the provider uses dates intervals in the form [start, stop]. Those boundaries
62+ * are expresses with timezone +00:00 (aka Z or UTC). An extra timezone parameter is given
63+ * in the query and let the server respond with datetimes shifted to this timezone.
6164 */
6265class Client extends AbstractClient
6366{
64- const RECORDS_URL = '/eco2mix-national-tr/records ' ;
6567 const EXPORT_URL_REALTIME = '/eco2mix-national-tr/exports/json ' ;
6668 const EXPORT_URL_CONSOLIDATED = '/eco2mix-national-cons-def/exports/json ' ;
6769
@@ -160,21 +162,18 @@ public function createZones(): int
160162 */
161163 public function fetchDay (DateTimeImmutable $ day , string $ zone ): array
162164 {
163- /** @var DBmysql $DB */
164- global $ DB ;
165-
166165 $ stop = $ day ->add (new DateInterval ('P1D ' ));
167166
168167 $ format = DateTime::ATOM ;
169- $ timezone = $ DB ->guessTimezone ();
170168 $ from = $ day ->format ($ format );
171169 $ to = $ stop ->format ($ format );
172170
171+ $ timezone = new DateTimeZone ('Europe/Paris ' );
173172 $ params = [
174173 'select ' => 'date_heure,taux_co2 ' ,
175174 'where ' => "date_heure IN [date' $ from' TO date' $ to'[ AND taux_co2 is not null " ,
176175 'order_by ' => 'date_heure asc ' ,
177- 'timezone ' => $ timezone ,
176+ 'timezone ' => $ timezone-> getName () ,
178177 ];
179178
180179 $ url = $ this ->base_url . self ::EXPORT_URL_REALTIME ;
@@ -187,6 +186,8 @@ public function fetchDay(DateTimeImmutable $day, string $zone): array
187186 return [];
188187 }
189188
189+ $ this ->step = $ this ->detectStep ($ response );
190+
190191 // Drop data with no carbon intensity (may be returned by the provider)
191192 $ response = array_filter ($ response , function ($ item ) {
192193 return $ item ['taux_co2 ' ] != 0 ;
@@ -207,7 +208,7 @@ public function fetchDay(DateTimeImmutable $day, string $zone): array
207208 }
208209 }
209210
210- return $ this -> formatOutput ( $ response, 15 ) ;
211+ return $ response ;
211212 }
212213
213214 /**
@@ -222,17 +223,15 @@ public function fetchDay(DateTimeImmutable $day, string $zone): array
222223 */
223224 public function fetchRange (DateTimeImmutable $ start , DateTimeImmutable $ stop , string $ zone , int $ dataset = self ::DATASET_REALTIME ): array
224225 {
225- /** @var DBmysql $DB */
226- global $ DB ;
227-
228226 // Build realtime and consolidated paths
229227 $ base_path = GLPI_PLUGIN_DOC_DIR . '/carbon/carbon_intensity/ ' . $ this ->getSourceName () . '/ ' . $ zone ;
230228 $ consolidated_dir = $ base_path . '/consolidated ' ;
231229 $ realtime_dir = $ base_path . '/realtime ' ;
232230
233231 // Set timezone to +00:00 and extend range by -12/+14 hours
234- $ request_start = $ start ->setTimezone (new DateTimeZone ('+0000 ' ))->sub (new DateInterval ('PT12H ' ));
235- $ request_stop = $ stop ->setTimezone (new DateTimeZone ('+0000 ' ))->add (new DateInterval ('PT14H ' ));
232+ $ timezone_z = new DateTimeZone ('+0000 ' );
233+ $ request_start = $ start ->setTimezone ($ timezone_z )->sub (new DateInterval ('PT12H ' ));
234+ $ request_stop = $ stop ->setTimezone ($ timezone_z )->add (new DateInterval ('PT14H ' ));
236235 $ format = DateTime::ATOM ;
237236 $ from = $ request_start ->format ($ format );
238237 $ to = $ request_stop ->format ($ format );
@@ -272,13 +271,13 @@ public function fetchRange(DateTimeImmutable $start, DateTimeImmutable $stop, st
272271 @mkdir (dirname ($ cache_file ), 0755 , true );
273272
274273 // Prepare the HTTP request
275- $ timezone = $ DB -> guessTimezone ();
274+ $ timezone = new DateTimeZone ( ' Europe/Paris ' ); // Optimal timezone to avoid DST mess in the response
276275 $ where = "date_heure IN [date' $ from' TO date' $ to'[ AND taux_co2 is not null " ;
277276 $ params = [
278277 'select ' => 'date_heure,taux_co2 ' ,
279278 'where ' => $ where ,
280279 'order_by ' => 'date_heure asc ' ,
281- 'timezone ' => $ timezone
280+ 'timezone ' => $ timezone-> getName ()
282281 ];
283282 $ response = $ this ->client ->request ('GET ' , $ url , ['timeout ' => 8 , 'query ' => $ params ]);
284283 $ this ->step = $ this ->detectStep ($ response );
@@ -301,46 +300,44 @@ public function fetchRange(DateTimeImmutable $start, DateTimeImmutable $stop, st
301300
302301 protected function getCacheFilename (string $ base_dir , DateTimeImmutable $ start , DateTimeImmutable $ end ): string
303302 {
303+ $ timezone_name = $ start ->getTimezone ()->getName ();
304+ $ timezone_name = str_replace ('/ ' , '- ' , $ timezone_name );
304305 return sprintf (
305- '%s/%s_%s.json ' ,
306+ '%s/%s_%s_% s.json ' ,
306307 $ base_dir ,
308+ $ timezone_name ,
307309 $ start ->format ('Y-m-d ' ),
308310 $ end ->format ('Y-m-d ' )
309311 );
310312 }
311313
314+ /**
315+ * Format the records before saving them in DB
316+ * It is assumed that the records are chronologically sorted
317+ *
318+ * @param array $response
319+ * @param integer $step
320+ * @return array
321+ */
312322 protected function formatOutput (array $ response , int $ step ): array
313323 {
314324 /** @var DBMysql $DB */
315325 global $ DB ;
316326
317- // array sort records, just in case
318- usort ($ response , function ($ a , $ b ) {
319- return $ a ['date_heure ' ] <=> $ b ['date_heure ' ];
320- });
321-
322327 $ this ->step = $ this ->detectStep ($ response );
323- // Deduplicate entries (solves switching from winter time to summer time)
324- // because there are 2 samples at same date time, during 1 hour
325- // Even if we use UTC timezone.
326- $ filtered_response = $ this ->deduplicate ($ response );
327-
328328 // Convert string dates into datetime objects,
329329 // using timezone expressed as type Continent/City instead of offset
330330 // This is needed to detect later the switching to winter time
331- $ local_timezone = new DateTimeZone ($ DB ->guessTimezone ());
332- array_walk ($ filtered_response , function (&$ item , $ key ) use ($ local_timezone ) {
333- $ item ['date_heure ' ] = DateTime::createFromFormat ('Y-m-d\TH:i:sP ' , $ item ['date_heure ' ])->setTimezone ($ local_timezone );
334- });
331+ $ response = $ this ->shiftToLocalTimezone ($ response );
335332
336333 // Convert samples from to 1 hour
337334 if ($ this ->step < 60 ) {
338- $ intensities = $ this ->convertToHourly ( $ filtered_response , $ this ->step );
335+ $ intensities = $ this ->downsample ( $ response , $ this ->step );
339336 } else {
340337 $ intensities = [];
341- foreach ($ filtered_response as $ record ) {
338+ foreach ($ response as $ local_datetime => $ record ) {
342339 $ intensities [] = [
343- 'datetime ' => $ record ['date_heure ' ]->format ('Y-m-d\TH:00:00 ' ),
340+ 'datetime ' => $ record ['date_heure ' ]->format ('Y-m-d\TH:00:00?????? ' ),
344341 'intensity ' => (float ) $ record ['taux_co2 ' ],
345342 'data_quality ' => AbstractTracked::DATA_QUALITY_RAW_REAL_TIME_MEASUREMENT ,
346343 ];
@@ -353,22 +350,56 @@ protected function formatOutput(array $response, int $step): array
353350 ];
354351 }
355352
353+ /**
354+ * convert dates to the timezone of GLPI
355+ *
356+ * @param array $response
357+ * @return array array of records: ['date_heure' => string, 'taux_co2' => number, 'datetime' => DateTime]
358+ */
359+ protected function shiftToLocalTimezone (array $ response ): array
360+ {
361+ /** @var DBMysql $DB */
362+ global $ DB ;
363+
364+ $ shifted_response = [];
365+ $ local_timezone = new DateTimeZone ($ DB ->guessTimezone ());
366+ array_walk ($ response , function ($ item , $ key ) use (&$ shifted_response , $ local_timezone ) {
367+ $ shifted_date_object = DateTime::createFromFormat ('Y-m-d\TH:i:sP ' , $ item ['date_heure ' ])
368+ ->setTimezone ($ local_timezone );
369+ $ shifted_date_string = $ shifted_date_object ->format ('Y-m-d H:i:sP ' );
370+ if (isset ($ shifted_response [$ shifted_date_string ]) && $ shifted_response ['taux_co2 ' ] !== $ item ['taux_co2 ' ]) {
371+ trigger_error ("Duplicate record with different carbon intensity detected. " );
372+ }
373+ $ item ['datetime ' ] = $ shifted_date_object ;
374+ $ shifted_response [$ shifted_date_string ] = $ item ;
375+ });
376+
377+ return $ shifted_response ;
378+ }
379+
380+ /**
381+ * Deduplicates records
382+ *
383+ * @param array $records Records in the format ['date_heure' => DateTime, 'taux_co2' => number]
384+ * @return array Array of records where key is the string formatted datetime and value is the carbon intensity
385+ */
356386 protected function deduplicate (array $ records ): array
357387 {
358- $ filtered_response = [];
388+ $ deduplicated = [];
359389 foreach ($ records as $ record ) {
360- if (isset ($ filtered_response [$ record ['date_heure ' ]])) {
361- if ($ filtered_response [$ record ['date_heure ' ]]['taux_co2 ' ] != $ record ['taux_co2 ' ]) {
390+ $ date = $ record ['date_heure ' ];
391+ if (key_exists ($ date , $ deduplicated )) {
392+ if ($ deduplicated [$ date ]['taux_co2 ' ] != $ record ['taux_co2 ' ]) {
362393 // Inconsistency detected. What to do with this record?
363394 continue ;
364395 }
365396 continue ;
366397 }
367398
368- $ filtered_response [ $ record [ ' date_heure ' ] ] = $ record ;
399+ $ deduplicated [ $ date ] = $ record ;
369400 }
370401
371- return $ filtered_response ;
402+ return $ deduplicated ;
372403 }
373404
374405 /**
@@ -402,6 +433,10 @@ protected function detectStep(array $records): ?int
402433 */
403434 protected function convertToHourly (array $ records , int $ step ): array
404435 {
436+ if ($ step === 60 ) {
437+ return $ records ;
438+ }
439+
405440 $ intensities = [];
406441 $ intensity = 0.0 ;
407442 $ count = 0 ;
@@ -454,6 +489,40 @@ protected function convertToHourly(array $records, int $step): array
454489 return $ intensities ;
455490 }
456491
492+ /**
493+ * Downsample records to a new set of records at the given frequency in minutes.
494+ * The records may have irregular interval between samples due to filtered out null elements
495+ *
496+ * @param array $records The records to downsample
497+ * @param int $step The step of output records in minutes
498+ * @return array The downsampled records
499+ */
500+ protected function downsample (array $ records , int $ step ): array
501+ {
502+ $ downsampled = [];
503+ $ intensity = 0.0 ;
504+ $ count = 0 ;
505+ foreach ($ records as $ record ) {
506+ $ date = $ record ['datetime ' ];
507+ $ intensity += $ record ['taux_co2 ' ];
508+ $ count ++;
509+ $ minute = (int ) $ date ->format ('i ' );
510+
511+ if ($ minute === (60 - $ step )) {
512+ // Finalizing an average of accumulated samples
513+ $ downsampled [] = [
514+ 'datetime ' => $ date ->format ('Y-m-d\TH:00:00 ' ),
515+ 'intensity ' => (float ) $ intensity / $ count ,
516+ 'data_quality ' => AbstractTracked::DATA_QUALITY_RAW_REAL_TIME_MEASUREMENT_DOWNSAMPLED ,
517+ ];
518+ $ intensity = 0.0 ;
519+ $ count = 0 ;
520+ }
521+ }
522+
523+ return $ downsampled ;
524+ }
525+
457526 /**
458527 * Detect if the given datetime matches a switching ot winter time (DST) for France
459528 *
0 commit comments