diff --git a/CHANGELOG.md b/CHANGELOG.md index 55d83a4..aad23f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.6] - February, 2026 +### Features +* Added the ability to retrieve observations from a single station at a time. +* Updated tests and documentation to reflect these additions +See #21 + ## [0.2.5] - January, 2026 ### Bugs Depending on the indicators, the geographic boundary envelope of the indicators may have a height component or other components. diff --git a/pyproject.toml b/pyproject.toml index 51ba5b5..1118214 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "meteole" -version = "0.2.5" +version = "0.2.6" requires-python = ">3.8.0" description = "A Python client library for forecast model APIs (e.g., Météo-France)." readme = "README.md" diff --git a/src/meteole/__init__.py b/src/meteole/__init__.py index cdd056a..e3534f7 100644 --- a/src/meteole/__init__.py +++ b/src/meteole/__init__.py @@ -4,9 +4,18 @@ from meteole._arome_ensemble import AromePEForecast from meteole._arome_instantane import AromePIForecast from meteole._arpege import ArpegeForecast +from meteole._dpclim import DPClim from meteole._piaf import PiafForecast from meteole._vigilance import Vigilance -__all__ = ["AromeForecast", "AromePIForecast", "ArpegeForecast", "PiafForecast", "Vigilance", "AromePEForecast"] +__all__ = [ + "AromeForecast", + "AromePIForecast", + "ArpegeForecast", + "PiafForecast", + "Vigilance", + "AromePEForecast", + "DPClim", +] __version__ = version("meteole") diff --git a/src/meteole/_dpclim.py b/src/meteole/_dpclim.py new file mode 100644 index 0000000..b97558e --- /dev/null +++ b/src/meteole/_dpclim.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import logging +from typing import final + +from meteole.clients import BaseClient, MeteoFranceClient +from meteole.climat import WeatherObservation + +logger = logging.getLogger(__name__) + + +@final +class DPClim(WeatherObservation): + """Access the "Données Climatologiques" data from Meteo-France API. + + Doc: + - https://https://portail-api.meteofrance.fr/web/fr/api/DonneesPubliquesClimatologie + """ + + # Model constants + MODEL_NAME: str = "DPClim" + BASE_ENTRY_POINT: str = "DPClim" + CLIENT_CLASS: type[BaseClient] = MeteoFranceClient + + def _validate_parameters(self) -> None: + """Check the territory and the precision parameters. + + Raise: + ValueError: At least, one parameter is not good. + """ + if self.frequency not in ("6m", "hourly", "daily", "decade", "monthly"): + raise ValueError("Parameter `frequency` must be in ('6m','hourly', 'daily', 'decade', 'monthly').") diff --git a/src/meteole/clients.py b/src/meteole/clients.py index dc7ac5a..df9f8b4 100644 --- a/src/meteole/clients.py +++ b/src/meteole/clients.py @@ -21,6 +21,9 @@ class HttpStatus(int, Enum): """Http status codes""" OK = 200 + FILE_SENT = 201 + REQUEST_ACCEPTED = 202 + ORDER_NOT_READY = 204 BAD_REQUEST = 400 UNAUTHORIZED = 401 FORBIDDEN = 403 @@ -118,7 +121,11 @@ def get(self, path: str, *, params: dict[str, Any] | None = None, max_retries: i try: resp: Response = self._session.get(url, params=params, verify=self._verify) - if resp.status_code == HttpStatus.OK: + if ( + resp.status_code == HttpStatus.OK + or resp.status_code == HttpStatus.REQUEST_ACCEPTED + or resp.status_code == HttpStatus.FILE_SENT + ): logger.debug("Successful request") return resp diff --git a/src/meteole/climat.py b/src/meteole/climat.py new file mode 100644 index 0000000..4209df3 --- /dev/null +++ b/src/meteole/climat.py @@ -0,0 +1,421 @@ +""" +in _fetch_capabilities, there is some error catching and logging going one. But not on the other fetch_... +methods. I did not implement it here, perhaps it could be added to the client class so that it is common to all +requests. +On the same topic, if an error arrises in client.get, it is reraised with another more generic error, and +(as far as I understand) the type of error is lost. It would be useful here to catch some errors to output more +useful messages + +From the doc: +erreur 404 "Not Found" lors de la recherche de stations => le département n'existe pas +erreur 400 "Bad Request" lors de la recherche de métadonnées d'une station ou du passage d'une commande => la station n'existe pas +erreur 400 "Bad Request" lors de la commande de données 6 minutes => la date de début est non conforme : +Pour les données 6 minutes, la règle est la suivante : les minutes doivent être un multiple de 6 (00, 06, 12, 18, 24, 30, 36, 42, 48, 54) +erreur 400 "Bad Request" lors de la commande de données => la date de fin est dans le futur +bad request with response body : la période demandée ne doit pas dépasser 1 an +erreur 500 "Error: Internal Server Error" et message "production en échec (la commande contient une plage d'absence de +données)" lors de la récupération d'une commande => la période recherchée est sur une période d'inactivité de la station + +""" + +from __future__ import annotations + +import datetime +import logging +import time +from abc import ABC, abstractmethod +from importlib.util import find_spec +from io import StringIO +from math import acos, cos, radians, sin +from typing import Any + +import pandas as pd + +from meteole.clients import BaseClient + +if find_spec("cfgrib") is None: + raise ImportError( + "The 'cfgrib' module is required to read Arome and Arpege GRIB files. Please install it using:\n\n" + " conda install -c conda-forge cfgrib\n\n" + ) +logger = logging.getLogger(__name__) + +NEIGHBOURS = { + "01": ("38", "39", "69", "71", "73", "74"), + "02": ("08", "51", "59", "60", "77", "80"), + "2A": ("2B",), + "2B": ("2A",), + "03": ("18", "23", "42", "58", "63", "71"), + "04": ("05", "06", "13", "83", "84", "26"), + "05": ("04", "26", "38", "73"), + "06": ("04", "83"), + "07": ("26", "30", "43", "48", "84"), + "08": ("02", "51", "55"), + "09": ("11", "31", "66"), + "10": ("21", "51", "52", "77", "89"), + "11": ("09", "31", "34", "66", "81"), + "12": ("15", "30", "34", "46", "48", "81", "82"), + "13": ("04", "30", "83", "84"), + "14": ("27", "50", "61"), + "15": ("12", "19", "43", "46", "48", "63"), + "16": ("17", "24", "79", "86", "87"), + "17": ("16", "33", "79", "85"), + "18": ("03", "36", "41", "45", "58"), + "19": ("15", "23", "24", "46", "63", "87"), + "21": ("10", "52", "58", "71", "89"), + "22": ("29", "35", "56"), + "23": ("03", "19", "36", "63", "87"), + "24": ("16", "19", "33", "46", "47", "87"), + "25": ("39", "70", "90"), + "26": ("04", "05", "07", "38", "84"), + "27": ("14", "28", "60", "76", "78", "95"), + "28": ("27", "41", "45", "61", "72", "78", "91"), + "29": ("22", "56"), + "30": ("07", "12", "13", "34", "48", "84"), + "31": ("09", "11", "32", "65", "81", "82"), + "32": ("31", "40", "47", "64", "65", "82"), + "33": ("17", "24", "40", "47"), + "34": ("11", "12", "30", "81"), + "35": ("22", "44", "49", "50", "53", "56"), + "36": ("18", "23", "37", "41", "86", "87"), + "37": ("36", "41", "49", "72", "86"), + "38": ("01", "05", "26", "73", "74"), + "39": ("01", "25", "71"), + "40": ("32", "33", "47", "64"), + "41": ("18", "28", "36", "37", "45", "72"), + "42": ("03", "43", "63", "69", "71"), + "43": ("07", "15", "42", "48", "63"), + "44": ("35", "49", "56", "85"), + "45": ("18", "28", "41", "77", "89", "91"), + "46": ("12", "15", "19", "24", "47", "82"), + "47": ("24", "32", "33", "40", "46", "82"), + "48": ("07", "12", "15", "30", "43"), + "49": ("35", "37", "44", "53", "72", "79", "85"), + "50": ("14", "35", "53", "61"), + "51": ("02", "08", "10", "52", "55", "77"), + "52": ("10", "21", "51", "55", "70", "88"), + "53": ("35", "49", "50", "61", "72"), + "54": ("55", "57", "88"), + "55": ("08", "51", "52", "54", "88"), + "56": ("22", "29", "35", "44"), + "57": ("54", "67", "88"), + "58": ("03", "18", "21", "71", "89"), + "59": ("02", "62", "80"), + "60": ("02", "27", "76", "77", "80", "95"), + "61": ("14", "28", "50", "53", "72"), + "62": ("59", "80"), + "63": ("03", "15", "19", "23", "42", "43"), + "64": ("32", "40", "65"), + "65": ("31", "32", "64"), + "66": ("09", "11"), + "67": ("57", "68", "88"), + "68": ("67", "88", "90"), + "69": ("01", "42", "71"), + "70": ("25", "52", "88", "90"), + "71": ("01", "03", "21", "39", "42", "58", "69"), + "72": ("28", "37", "41", "49", "53", "61"), + "73": ("01", "05", "38", "74"), + "74": ("01", "38", "73"), + "75": ("92", "93", "94"), + "76": ("27", "60", "80"), + "77": ("02", "10", "45", "51", "60", "89", "91", "93", "94"), + "78": ("27", "28", "91", "92", "95"), + "79": ("16", "17", "49", "85", "86"), + "80": ("02", "59", "60", "62", "76"), + "81": ("11", "12", "31", "34", "82"), + "82": ("12", "31", "32", "46", "47", "81"), + "83": ("04", "06", "13", "84"), + "84": ("04", "07", "13", "26", "30", "83"), + "85": ("17", "44", "49", "79"), + "86": ("16", "36", "37", "79", "87"), + "87": ("16", "19", "23", "24", "36", "86"), + "88": ("52", "54", "55", "57", "67", "68", "70"), + "89": ("10", "21", "45", "58", "77"), + "90": ("25", "68", "70"), + "91": ("28", "45", "77", "78", "92", "94"), + "92": ("75", "78", "91", "93", "94"), + "93": ("75", "77", "92", "94", "95"), + "94": ("75", "77", "91", "92", "93"), + "95": ("27", "60", "78", "93"), + "99": tuple(), + "971": tuple(), + "972": tuple(), + "973": tuple(), + "974": tuple(), + "975": tuple(), + "984": tuple(), + "985": tuple(), + "986": tuple(), + "987": tuple(), + "988": tuple(), +} + + +def _format_departement(departement: int | str) -> str: + """Formats a departement given as an int or a str into the proper two-character code + + ex: "01" -> "01", 1 -> "01", "1" -> "01", "unknown" -> ValueError + """ + if isinstance(departement, float): + raise ValueError("Invalid type (float) for departement, give it as str or int") + if isinstance(departement, str) and len(departement) == 1: + departement = "0" + departement + if isinstance(departement, int): + departement = f"{departement:02}" + if departement not in NEIGHBOURS: + raise ValueError(f"Invalid departement {departement}") + return departement + + +def _distance_from_coords(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """ + Computes the distance between two coordinates + + Args: + lat1, lon1 (float): lat, lon of first point + lat2, lon2 (float): lat, lon of second point + + Returns: + float: distance (in km) between points. + """ + + lat1, lon1 = radians(lat1), radians(lon1) + lat2, lon2 = radians(lat2), radians(lon2) + return 6371.01 * acos(sin(lat1) * sin(lat2) + cos(lat1) * cos(lat2) * cos(lon1 - lon2)) + + +def sort_stations_by_distance(lat: float, lon: float, stations: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Sorts a list of stations by distance to a given point (lat, long) + Returns a copy of the list, sorted + """ + + def _distance_to_point(station: dict[str, Any]) -> float: + return _distance_from_coords(lat, lon, station["lat"], station["lon"]) + + return sorted(stations, key=_distance_to_point) + + +class WeatherObservation(ABC): + """(Abstract) + Base class for weather observation models. + + Attributes: + frequency: frequency of the observation ('6m','hourly', 'daily', 'decade', 'monthly') + """ + + # Class constants + # Global + API_VERSION: str = "v1" + FREQUENCY_TO_ENDPOINT: dict[str, str] = { + "6m": "infrahoraire-6m", + "hourly": "horaire", + "daily": "quotidienne", + "decade": "decadaire", + "monthly": "mensuelle", + } + # Model + MODEL_NAME: str = "Defined in subclass" + BASE_ENTRY_POINT: str = "Defined in subclass" + MODEL_TYPE: str = "Observation" + DEFAULT_FREQUENCY: str = "hourly" + CLIENT_CLASS: type[BaseClient] + + def __init__( + self, + client: BaseClient | None = None, + *, + frequency: str = DEFAULT_FREQUENCY, + **kwargs: Any, + ): + """Initialize attributes. + + Args: + frequency: the observation frequency: '6m','hourly', 'daily', 'decade', 'monthly' + api_key: The API key for authentication. Defaults to None. + token: The API token for authentication. Defaults to None. + application_id: The Application ID for authentication. Defaults to None. + """ + + self.frequency = frequency + self._validate_parameters() + + self._entry_point = f"{self.BASE_ENTRY_POINT}/{self.API_VERSION}" + + # Stations are fetched and listed by departement number + self._stations: dict[str, Any] = {} + + # Stations info are fetched and stored by station_id + self._stations_info: dict[str, Any] = {} + + if client is not None: + self._client = client + else: + # Try to instantiate it (can be user friendly) + self._client = self.CLIENT_CLASS(**kwargs) + + @abstractmethod + def _validate_parameters(self) -> None: + pass + + def get_stations( + self, + departement: int | str, + lat: float | None = None, + lon: float | None = None, + add_neighbours: bool = True, + open_only: bool = True, + ) -> list[dict[str, Any]]: + """ + Returns a list of station for a given departement. + + Typically, one needs the closest or the N closest stations to a given point. When lat, lon parameters are + specified, the results are sorted by distance to that point, with the closest station first in the list. + + In the same vein, setting add_neighbours to True adds stations from neighbouring departements. This is useful + when looking for stations close to the border of a departement. + + Args: + departement: departement number (int or str) + add_neighbours: bool (default True), whether to add stations from bordering departements + open_only: bool (default True), whether to return only stations currently open + + if lat, lon are given, the stations are sorted by distance to this point + """ + departement = _format_departement(departement) + + if departement not in self._stations: + self._stations[departement] = self._fetch_stations(departement) + + out = self._stations[departement] + if open_only: + out = [station for station in out if station.get("posteOuvert", False)] + + if add_neighbours: + for neighbour in NEIGHBOURS[departement]: + out.extend(self.get_stations(neighbour, add_neighbours=False, open_only=open_only)) + + if lat and lon: + out = sort_stations_by_distance(lat, lon, out) + + return out + + def _fetch_stations(self, departement: int | str) -> list[dict[str, Any]]: + """Fetch all stations from a departement + + Args: + departement: departement number (int or str) + + Returns: + list of stations. each station is a dict. + """ + url = f"{self._entry_point}/liste-stations/" + if self.frequency in ("6m", "hourly"): + url += self.FREQUENCY_TO_ENDPOINT[self.frequency] + else: + url += "quotidienne" + + params = {"id-departement": _format_departement(departement)} + + response = self._client.get(url, params=params) + return response.json() + + def get_station_info(self, station_id: str) -> dict[str, Any]: + """Returns the information for a particular station as a dict (raw output from the API) + Caches the information for future use + """ + if station_id not in self._stations_info: + self._stations_info[station_id] = self._fetch_station_info(station_id) + return self._stations_info[station_id] + + def _fetch_station_info(self, station_id: str) -> dict[str, Any]: + """ + Gets the information for a particular station + """ + url = f"{self._entry_point}/information-station" + params = {"id-station": station_id} + + response = self._client.get(url, params=params) + + response = response.json()[0] # returns a list with one element + + print(type(response["id"])) + + # There seem to be a bug in the API where the ID of the station has been transformed into an integer, which + # suppresses leading zeros. We fix it here : convert to str and add leading zeros if necessary. + # We checked that it is not due to the conversion in response.json() : it is already truncated in response.text + if len(id_str := str(response["id"])) < 8: + id_str = "0" * (8 - len(id_str)) + id_str + response["id"] = id_str + + return response + + def fetch_data( + self, station_id: str, start: datetime.datetime | str, end: datetime.datetime | str, wait_for_file: int = 5 + ) -> pd.DataFrame: + """Get observations for a station and a given time period. Fetching the data happens in two steps: first, an + order is placed, it is processed by the server, and after a certain waiting time, the file is retrieved. + + Args: + station_id (str): the station id + start and end (str or datetime): time period. Can be given as a datetime or a str. As a str, + must be of the form : AAAA-MM-JJThh:mm:00Z, with minutes and hours depending on the frequency. + For hourly data, minutes must be 00. For daily data, hours and minutes must be 00:00. For 6-minute + data, minutes must be a multiple of 6 (00, 06, 12, 18, 24, 30, 36, 42, 48, 54). + wait_for_file (int): number of seconds to wait before retrieving the file after ordering it. + + Returns: + pd.DataFrame: The forecast for the specified time. + """ + + if isinstance(start, datetime.datetime): + start = self._format_datetime(start) + if isinstance(end, datetime.datetime): + end = self._format_datetime(end) + + # order data and retrieve order ID + self._order_id = self._order_data(station_id, start, end) + + # file is typically ready after a few seconds + time.sleep(wait_for_file) + file_content = self._retrieve_file(order_id=self._order_id) + return pd.read_csv(StringIO(file_content), delimiter=";", decimal=",") + + def _order_data(self, station_id: str, start: str, end: str) -> str: + """ + Orders data for a given station and period + Returns the order ID + """ + url = f"{self._entry_point}/commande-station/{self.FREQUENCY_TO_ENDPOINT[self.frequency]}" + + params = {"id-station": station_id, "date-deb-periode": start, "date-fin-periode": end} + + response = self._client.get(url, params=params).json() + return response["elaboreProduitAvecDemandeResponse"]["return"] + + def _retrieve_file(self, order_id: str) -> str: + """Retrieve the file corresponsing to order_id + HTTPERROR 204 indicates that the file is not yet ready + + Returns .csv file content as a string + """ + url = f"{self._entry_point}/commande/fichier" + params = {"id-cmde": order_id} + response = self._client.get(url, params=params) + return response.text + + def _format_datetime(self, dt: datetime.datetime) -> str: + """Checks and formats the start and end dates for ordering data + Trims the datetimes to the appropriate precision + """ + # Trim to proper precision + dt = dt.replace(second=0, microsecond=0) + + if self.frequency == "6m": + # For 6-minute data, minutes must be a multiple of 6 (00, 06, 12, 18, 24, 30, 36, 42, 48, 54) + dt = dt.replace(minute=int(dt.minute / 6) * 6) + elif self.frequency == "hourly": + dt = dt.replace(minute=0) + elif self.frequency in ("daily", "decade", "monthly"): + dt = dt.replace(hour=0, minute=0) + + return dt.isoformat().removesuffix("+00:00") + "Z" diff --git a/tutorial/Get_observations.ipynb b/tutorial/Get_observations.ipynb new file mode 100644 index 0000000..ecaa552 --- /dev/null +++ b/tutorial/Get_observations.ipynb @@ -0,0 +1,161 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Observations\n", + "\n", + "This tutorial will help you access the observation, through the DPClimat API, which provides access to the data of the Météo France observation network.\n", + "\n", + "The main object of interest is the DPClim, which is initialized with your application ID and an observation frequency.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "from meteole import DPClim\n", + "\n", + "APP_ID = \"\"\n", + "\n", + "# Initialize the client with the observation frequency: \"6m\",\"hourly\",\"daily\",\"decade\",\"monthly\"\n", + "client = DPClim(application_id=APP_ID, frequency=\"daily\")" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "Observations are requested with the fetch_data method for a particular station and a particular date range. The station is identified by its code. We explore later methods to help with identifying the station code. The start and end dates are specified as ISO format strings, or as datetime objects. data is returned as a pandas dataframe, with one row per observation and columns the differente parameters of the observation, such as temperature, humidity, etc. The exact parameters available depend on the station and the frequency of the observations." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "station_id = \"01014002\"\n", + "data = client.fetch_data(station_id, \"2024-01-01T00:00:00Z\", \"2024-01-05T00:00:00Z\")\n", + "print(data.head())\n", + "\n", + "# equivalent version with datetime\n", + "# from datetime import datetime\n", + "# data = client.fetch_data(station_id, datetime(2024,1,1), datetime(2024,1,5))" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "# A two step process\n", + "\n", + "One thing to keep in mind is the request process. When you call fetch_data, the client sends a request to the DPClimat API. The API then processes the request, which may take some time depending on the size of the data requested and the current load on the server. fetch_data waits a 5 seconds by default (controlled by wait_for_file parameter) before requesting the processed data. If the data is not available, it is treated as a \"normal\" error and the request is retried as defined in the MeteoFranceClient.\n", + "\n", + "If the data were to be unavailable still, the order_id is stored as client._order_id. It can be used to request the data later, with client._retrieve_file(client._order_id)" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, + "source": [ + "# Exploring available stations\n", + "\n", + "The other methods in DPClim are designed to help you explore the available stations and their characteristics, in order to identify the station code to use in fetch_data.\n", + "\n", + "get_stations should be your first stop. It returns all stations from a departement (identified by its number). Each station is a dataframe with its basic characteristics, and in particular its 'id' used in fetch_data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "# the result of client.get_stations is cached and stored in the client._stations dict.\n", + "stations = client.get_stations(1) # or client.get_stations(\"01\") or client.get_stations(\"1\")\n", + "\n", + "print(\"First station in the departement:\")\n", + "print(stations[0])\n", + "\n", + "# Options of get_stations:\n", + "stations = client.get_stations(\n", + " 1,\n", + " lat=46.20426602709911, # if lat, lon are given, stations are sorted by distance\n", + " lon=5.2187206834000435,\n", + " add_neighbours=False, # add neighbouring departements, default True\n", + " open_only=False,\n", + ") # only return stations that are currently active, default True\n", + "\n", + "print(\"Closes station to the given coordinates:\")\n", + "print(\"Notice how it is different from the previous call\")\n", + "print(stations[0])" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "The get_station_infom method provides more detailed information about a station, including its geographical coordinates, the parameters it observes, and its observation history. It can be used to check if a station has the parameters you are interested in, and if it has observations for the date range you want to fetch. It is, as exepected, called with the id of the station, and returns a dict." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "info = client.get_station_info(stations[0][\"id\"])\n", + "\n", + "print(\"Keys of the info dict:\")\n", + "print(info.keys())" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "meteole_env", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "undefined.undefined.undefined" + }, + "toc": { + "base_numbering": 1, + "nav_menu": {}, + "number_sections": true, + "sideBar": true, + "skip_h1_title": false, + "title_cell": "Table of Contents", + "title_sidebar": "Contents", + "toc_cell": false, + "toc_position": {}, + "toc_section_display": true, + "toc_window_display": false + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}