Esempio n. 1
0
    def get_range(standard_name, level="total", typ=None):
        """
        Returns valid range of values for target at given level
        Args:
            standard_name: string of CF standard_name
            level (optional): horizontal level of data
            type (optional): type of data (pl, ml, tl, ...)

        Returns:
            Tuple of lowest and highest valid value
        """
        if standard_name in Targets.RANGES:
            if level == "total" and "total" in Targets.RANGES[standard_name]:
                unit, values = Targets.RANGES[standard_name]["total"]
                return convert_to(values, unit,
                                  Targets.get_unit(standard_name))
            if typ in Targets.RANGES[standard_name]:
                if level in Targets.RANGES[standard_name][typ]:
                    unit, values = Targets.RANGES[standard_name][typ][level]
                    return convert_to(values, unit,
                                      Targets.get_unit(standard_name))
                elif level is None:
                    return 0, 0
        if standard_name.startswith("surface_origin_tracer_from_"):
            return 0, 100
        return None, None
Esempio n. 2
0
 def _prepare_datafields(self):
     """
     Computes vertical velocity in cm/s.
     """
     self.data["upward_wind"] = convert_to(
         thermolib.omega_to_w(self.data["lagrangian_tendency_of_air_pressure"],
                              self.data['air_pressure'], self.data["air_temperature"]),
         "m/s", "cm/s")
     self.variable = "upward_wind"
     self.y_values = self.data[self.variable]
     self.unit = "cm/s"
Esempio n. 3
0
 def verticalunitsclicked(self, index):
     new_unit = self._suffixes[index]
     old_unit = self.sbPbot.suffix().strip()
     if new_unit == old_unit:
         return
     self.setBotTopLimits("maximum")
     for sb in (self.sbPbot, self.sbPtop):
         sb.setSuffix(" " + new_unit)
         if new_unit == "hPa":
             sb.setValue(
                 thermolib.flightlevel2pressure(
                     convert_to(sb.value(), old_unit, "hft", 1) *
                     units.hft).to(units.hPa).magnitude)
         elif old_unit == "hPa":
             sb.setValue(
                 convert_to(
                     thermolib.pressure2flightlevel(
                         sb.value() * units.hPa).magnitude, "hft",
                     new_unit))
         else:
             sb.setValue(convert_to(sb.value(), old_unit, new_unit, 1))
     self.setBotTopLimits(self.cbVerticalAxis.currentText())
Esempio n. 4
0
    def get_thresholds(standard_name):
        """
        Returns a list of meaningful values for a BoundaryNorm for plotting.
        Args:
            standard_name: string of CF standard_name
            level (optional): horizontal level of data
            type (optional): type of data (pl, ml, tl, ...)

        Returns:
            Tuple of threshold values to be supplied to a BoundaryNorm.
        """
        try:
            threshold_unit, thresholds = Targets.THRESHOLDS[standard_name]
            return convert_to(thresholds, threshold_unit,
                              Targets.get_unit(standard_name))
        except KeyError:
            return None
Esempio n. 5
0
def test_convert_to():
    assert convert_to(10, "km", "m", None) == 10000
    assert convert_to(1000, "m", "km", None) == 1

    assert convert_to(1000, "Pa", "hPa", None) == 10
    assert convert_to(1000, "hPa", "Pa", None) == 100000

    assert convert_to(10, "degC", "K", None) == 283.15

    assert convert_to(10 * 9.81, "m^2s^-2", "m", None) == pytest.approx(10)
    assert convert_to(10 * 9.81, "m**2s**-2", "m", None) == pytest.approx(10)

    with pytest.raises(TypeError):
        assert convert_to(10, "m", "m**2s**-2", None) == 10

    assert convert_to(10, "m", "m**2s**-2", 1) == 10
    assert convert_to(10, "m", "m**2s**-2", 9.81) == pytest.approx(98.1)

    assert convert_to(1000, "", "Pa", 999) == 999000
    assert convert_to(1000, "Pa", "", 999) == 999000
    assert convert_to(1000, "whattheheck", "Pa", 999) == 999000
    assert convert_to(1000, "hPa", "whattheheck", 999) == 999000
    assert convert_to(1000, "whattheheck", "whatthehock", 999) == 999000

    assert convert_to(10, "percent", "dimensionless", None) == 0.1
    assert convert_to(10, "permille", "dimensionless", None) == 0.01
    assert convert_to(10, "ppm", "dimensionless", None) == pytest.approx(10e-6)
    assert convert_to(10, "ppb", "dimensionless", None) == pytest.approx(10e-9)
    assert convert_to(10, "ppt", "dimensionless",
                      None) == pytest.approx(10e-12)
    assert convert_to(10, "ppm", "ppt", None) == pytest.approx(10e6)
    assert convert_to(10, "ppb", "ppm", None) == pytest.approx(10e-3)
Esempio n. 6
0
    def plot_hsection(self,
                      data,
                      lats,
                      lons,
                      bbox=(-180, -90, 180, 90),
                      level=None,
                      figsize=(960, 640),
                      crs=None,
                      proj_params=None,
                      valid_time=None,
                      init_time=None,
                      style=None,
                      resolution=-1,
                      noframe=False,
                      show=False,
                      transparent=False):
        """
        EPSG overrides proj_params!
        """
        if proj_params is None:
            proj_params = {"projection": "cyl"}
            bbox_units = "latlon"
        # Projection parameters from EPSG code.
        if crs is not None:
            proj_params, bbox_units = [
                get_projection_params(crs)[_x] for _x in ("basemap", "bbox")
            ]

        logging.debug("plotting data..")

        # Check if required data is available.
        self.data_units = self.driver.data_units.copy()
        for datatype, dataitem, dataunit in self.required_datafields:
            if dataitem not in data:
                raise KeyError(f"required data field '{dataitem}' not found")
            origunit = self.driver.data_units[dataitem]
            if dataunit is not None:
                data[dataitem] = convert_to(data[dataitem], origunit, dataunit)
                self.data_units[dataitem] = dataunit
            else:
                logging.debug("Please add units to plot variables")

        # Copy parameters to properties.
        self.data = data
        self.lats = lats
        self.lons = lons
        self.level = level
        self.valid_time = valid_time
        self.init_time = init_time
        self.style = style
        self.resolution = resolution
        self.noframe = noframe
        self.crs = crs

        # Derive additional data fields and make the plot.
        logging.debug("preparing additional data fields..")
        self._prepare_datafields()

        logging.debug("creating figure..")
        dpi = 80
        figsize = (figsize[0] / dpi), (figsize[1] / dpi)
        facecolor = "white"
        fig = mpl.figure.Figure(figsize=figsize, dpi=dpi, facecolor=facecolor)
        logging.debug(
            "\twith frame and legends" if not noframe else "\twithout frame")
        if noframe:
            ax = fig.add_axes([0.0, 0.0, 1.0, 1.0])
        else:
            ax = fig.add_axes([0.05, 0.05, 0.9, 0.88])

        # The basemap instance is created with a fixed aspect ratio for framed
        # plots; the aspect ratio is not fixed for frameless plots (standard
        # WMS). This means that for WMS plots, the map will always fill the
        # entire image area, no matter of whether this stretches or shears the
        # map. This is the behaviour specified by the WMS standard (WMS Spec
        # 1.1.1, Section 7.2.3.8):
        # "The returned picture, regardless of its return format, shall have
        # exactly the specified width and height in pixels. In the case where
        # the aspect ratio of the BBOX and the ratio width/height are different,
        # the WMS shall stretch the returned map so that the resulting pixels
        # could themselves be rendered in the aspect ratio of the BBOX.  In
        # other words, it should be possible using this definition to request a
        # map for a device whose output pixels are themselves non-square, or to
        # stretch a map into an image area of a different aspect ratio."
        # NOTE: While the MSUI always requests image sizes that match the aspect
        # ratio, for instance the Metview 4 client does not (mr, 2011Dec16).

        # Some additional code to store the last 20 coastlines in memory for quicker
        # access.
        key = repr((proj_params, bbox, bbox_units))
        basemap_use_cache = getattr(mswms_settings, "basemap_use_cache", False)
        basemap_request_size = getattr(mswms_settings, "basemap_request_size ",
                                       200)
        basemap_cache_size = getattr(mswms_settings, "basemap_cache_size", 20)
        bm_params = {
            "area_thresh": 1000.,
            "ax": ax,
            "fix_aspect": (not noframe)
        }
        bm_params.update(proj_params)
        if bbox_units == "degree":
            bm_params.update({
                "llcrnrlon": bbox[0],
                "llcrnrlat": bbox[1],
                "urcrnrlon": bbox[2],
                "urcrnrlat": bbox[3]
            })
        elif bbox_units.startswith("meter"):
            # convert meters to degrees
            try:
                bm_p = basemap.Basemap(resolution=None, **bm_params)
            except ValueError:  # projection requires some extent
                bm_p = basemap.Basemap(resolution=None,
                                       width=1e7,
                                       height=1e7,
                                       **bm_params)
            bm_center = [float(_x) for _x in bbox_units[6:-1].split(",")]
            center_x, center_y = bm_p(*bm_center)
            bbox_0, bbox_1 = bm_p(bbox[0] + center_x,
                                  bbox[1] + center_y,
                                  inverse=True)
            bbox_2, bbox_3 = bm_p(bbox[2] + center_x,
                                  bbox[3] + center_y,
                                  inverse=True)
            bm_params.update({
                "llcrnrlon": bbox_0,
                "llcrnrlat": bbox_1,
                "urcrnrlon": bbox_2,
                "urcrnrlat": bbox_3
            })
        elif bbox_units == "no":
            pass
        else:
            raise ValueError(f"bbox_units '{bbox_units}' not known.")
        if basemap_use_cache and key in BASEMAP_CACHE:
            bm = basemap.Basemap(resolution=None, **bm_params)
            (bm.resolution, bm.coastsegs, bm.coastpolygontypes,
             bm.coastpolygons, bm.coastsegs, bm.landpolygons, bm.lakepolygons,
             bm.cntrysegs) = BASEMAP_CACHE[key]
            logging.debug("Loaded '%s' from basemap cache", key)
        else:
            bm = basemap.Basemap(resolution='l', **bm_params)
            # read in countries manually, as those are laoded only on demand
            bm.cntrysegs, _ = bm._readboundarydata("countries")
            if basemap_use_cache:
                BASEMAP_CACHE[key] = (bm.resolution, bm.coastsegs,
                                      bm.coastpolygontypes, bm.coastpolygons,
                                      bm.coastsegs, bm.landpolygons,
                                      bm.lakepolygons, bm.cntrysegs)
        if basemap_use_cache:
            BASEMAP_REQUESTS.append(key)
            BASEMAP_REQUESTS[:] = BASEMAP_REQUESTS[-basemap_request_size:]

            if len(BASEMAP_CACHE) > basemap_cache_size:
                useful = {}
                for idx, key in enumerate(BASEMAP_REQUESTS):
                    useful[key] = useful.get(key, 0) + idx
                least_useful = sorted([
                    (value, key) for key, value in useful.items()
                ])[:-basemap_cache_size]
                for _, key in least_useful:
                    del BASEMAP_CACHE[key]
                    BASEMAP_REQUESTS[:] = [
                        _x for _x in BASEMAP_REQUESTS if key != _x
                    ]

        if self._plot_countries:
            # Set up the map appearance.
            try:
                bm.drawcoastlines(color='0.25')
            except ValueError as ex:
                logging.error(
                    "Error in basemap/matplotlib call of drawcoastlines: %s",
                    ex)
            bm.drawcountries(color='0.5')
            bm.drawmapboundary(fill_color='white')

            # zorder = 0 is necessary to paint over the filled continents with
            # scatter() for drawing the flight tracks and trajectories.
            # Curiously, plot() works fine without this setting, but scatter()
            # doesn't.
            bm.fillcontinents(color='0.98', lake_color='white', zorder=0)
            self._draw_auto_graticule(bm)

        if noframe:
            ax.axis('off')

        self.bm = bm  # !! BETTER PASS EVERYTHING AS PARAMETERS?
        self.fig = fig
        self.shift_data()
        self.mask_data()
        self.lonmesh, self.latmesh = bm(*np.meshgrid(self.lons, self.lats))
        self._plot_style()

        # Set transparency for the output image.
        if transparent:
            fig.patch.set_alpha(0.)

        # Return the image as png embedded in a StringIO stream.
        canvas = FigureCanvas(fig)
        output = io.BytesIO()
        canvas.print_png(output)

        if show:
            logging.debug("saving figure to mpl_hsec.png ..")
            canvas.print_png("mpl_hsec.png")

        # Convert the image to an 8bit palette image with a significantly
        # smaller file size (~factor 4, from RGBA to one 8bit value, plus the
        # space to store the palette colours).
        # NOTE: PIL at the current time can only create an adaptive palette for
        # RGB images, hence alpha values are lost here. If transparency is
        # requested, the figure face colour is stored as the "transparent"
        # colour in the image. This works in most cases, but might lead to
        # visible artefacts in some cases.
        logging.debug("converting image to indexed palette.")
        # Read the above stored png into a PIL image and create an adaptive
        # colour palette.
        output.seek(0)  # necessary for PIL.Image.open()
        palette_img = PIL.Image.open(output).convert(mode="RGB").convert(
            "P", palette=PIL.Image.ADAPTIVE)
        output = io.BytesIO()
        if not transparent:
            logging.debug("saving figure as non-transparent PNG.")
            palette_img.save(
                output,
                format="PNG")  # using optimize=True doesn't change much
        else:
            # If the image has a transparent background, we need to find the
            # index of the background colour in the palette. See the
            # documentation for PIL's ImagePalette module
            # (http://www.pythonware.com/library/pil/handbook/imagepalette.htm). The
            # idea is to create a 256 pixel image with the same colour palette
            # as the original image and use it as a lookup-table. Converting the
            # lut image back to RGB gives us a list of all colours in the
            # palette. (Why doesn't PIL provide a method to directly access the
            # colours in a palette??)
            lut = palette_img.resize((256, 1))
            lut.putdata(list(range(256)))
            lut = [c[1] for c in lut.convert("RGB").getcolors()]
            facecolor_rgb = list(
                mpl.colors.hex2color(mpl.colors.cnames[facecolor]))
            for i in [0, 1, 2]:
                facecolor_rgb[i] = int(facecolor_rgb[i] * 255)
            try:
                facecolor_index = lut.index(tuple(facecolor_rgb))
                logging.debug(
                    "saving figure as transparent PNG with transparency index %s.",
                    facecolor_index)
                palette_img.save(output,
                                 format="PNG",
                                 transparency=facecolor_index)
            except ValueError:
                logging.debug(
                    "transparency requested but not possible, saving non-transparent instead"
                )
                palette_img.save(output, format="PNG")

        logging.debug("returning figure..")
        return output.getvalue()
Esempio n. 7
0
    def plot_lsection(self, data, lats, lons, valid_time, init_time):
        """
        """
        # Check if required data is available.
        self.data_units = self.driver.data_units.copy()
        for datatype, dataitem, dataunit in self.required_datafields:
            if dataitem not in data:
                raise KeyError(f"required data field '{dataitem}' not found")
            origunit = self.driver.data_units[dataitem]
            if dataunit is not None:
                data[dataitem] = convert_to(data[dataitem], origunit, dataunit)
                self.data_units[dataitem] = dataunit
            else:
                logging.debug("Please add units to plot variables")

        # Copy parameters to properties.
        self.data = data
        self.lats = lats
        self.lons = lons
        self.valid_time = valid_time
        self.init_time = init_time

        # Derive additional data fields and make the plot.
        self._prepare_datafields()

        impl = getDOMImplementation()
        xmldoc = impl.createDocument(None, "MSS_LinearSection_Data", None)

        # Title of this section.
        node = xmldoc.createElement("Title")
        node.appendChild(xmldoc.createTextNode(self.title))
        xmldoc.documentElement.appendChild(node)

        # Time information of this section.
        node = xmldoc.createElement("ValidTime")
        node.appendChild(
            xmldoc.createTextNode(
                self.valid_time.strftime("%Y-%m-%dT%H:%M:%SZ")))
        xmldoc.documentElement.appendChild(node)

        node = xmldoc.createElement("InitTime")
        node.appendChild(
            xmldoc.createTextNode(
                self.init_time.strftime("%Y-%m-%dT%H:%M:%SZ")))
        xmldoc.documentElement.appendChild(node)

        # Longitude data.
        node = xmldoc.createElement("Longitude")
        node.setAttribute("num_waypoints", f"{len(self.lons)}")
        data_str = ",".join([str(lon) for lon in self.lons])

        node.appendChild(xmldoc.createTextNode(data_str))
        xmldoc.documentElement.appendChild(node)

        # Latitude data.
        node = xmldoc.createElement("Latitude")
        node.setAttribute("num_waypoints", f"{len(self.lats)}")
        data_str = ",".join([str(lat) for lat in self.lats])

        node.appendChild(xmldoc.createTextNode(data_str))
        xmldoc.documentElement.appendChild(node)

        # Variable data.
        node = xmldoc.createElement("Data")
        node.setAttribute("num_waypoints", f"{len(self.y_values)}")
        node.setAttribute("unit", self.unit)
        if isinstance(self.y_values[0], Quantity):
            data_str = ",".join([str(val.magnitude) for val in self.y_values])
        else:
            data_str = ",".join([str(val) for val in self.y_values])

        node.appendChild(xmldoc.createTextNode(data_str))
        xmldoc.documentElement.appendChild(node)

        # Return the XML document as formatted string.
        return xmldoc.toprettyxml(indent="  ")
Esempio n. 8
0
    def plot_vsection(self,
                      data,
                      lats,
                      lons,
                      valid_time,
                      init_time,
                      resolution=(-1, -1),
                      bbox=(-1, 1050, -1, 200),
                      style=None,
                      show=False,
                      highlight=None,
                      noframe=False,
                      figsize=(960, 480),
                      draw_verticals=False,
                      numlabels=10,
                      orography_color='k',
                      transparent=False,
                      return_format="image/png"):
        """
        """
        # Check if required data is available.
        self.data_units = self.driver.data_units.copy()
        for datatype, dataitem, dataunit in self.required_datafields:
            if dataitem not in data:
                raise KeyError(f"required data field '{dataitem}' not found")
            origunit = self.driver.data_units[dataitem]
            if dataunit is not None:
                data[dataitem] = convert_to(data[dataitem], origunit, dataunit)
                self.data_units[dataitem] = dataunit
            else:
                logging.debug("Please add units to plot variables")

        # Copy parameters to properties.
        self.data = data
        self.lats = lats
        self.lat_inds = np.arange(len(lats))
        self.lons = lons
        self.valid_time = valid_time
        self.init_time = init_time
        self.resolution = resolution
        self.style = style
        self.highlight = highlight
        self.noframe = noframe
        self.draw_verticals = draw_verticals
        self.p_bot = bbox[1] * 100
        self.p_top = bbox[3] * 100
        self.numlabels = numlabels
        self.orography_color = orography_color

        # Provide an air_pressured 2-D field in 'Pa' from vertical axis
        if (("air_pressure" not in self.data)
                and units(self.driver.vert_units).check("[pressure]")):
            self.data_units["air_pressure"] = "Pa"
            self.data["air_pressure"] = convert_to(
                self.driver.vert_data[::-self.driver.vert_order,
                                      np.newaxis], self.driver.vert_units,
                self.data_units["air_pressure"]).repeat(len(self.lats), axis=1)
        if (("air_potential_temperature" not in self.data)
                and units(self.driver.vert_units).check("[temperature]")):
            self.data_units["air_potential_temperature"] = "K"
            self.data["air_potential_temperature"] = convert_to(
                self.driver.vert_data[::-self.driver.vert_order,
                                      np.newaxis], self.driver.vert_units,
                self.data_units["air_potential_temperature"]).repeat(len(
                    self.lats),
                                                                     axis=1)

        # Derive additional data fields and make the plot.
        self._prepare_datafields()
        if "air_pressure" not in self.data:
            raise KeyError(
                "'air_pressure' need to be available for VSEC plots."
                "Either provide as data or compute in _prepare_datafields")

        # Code for producing a png image with Matplotlib.
        # ===============================================
        if return_format == "image/png":

            logging.debug("creating figure..")
            dpi = 80
            figsize = (figsize[0] / dpi), (figsize[1] / dpi)
            facecolor = "white"
            self.fig = mpl.figure.Figure(figsize=figsize,
                                         dpi=dpi,
                                         facecolor=facecolor)
            logging.debug("\twith frame and legends"
                          if not noframe else "\twithout frame")
            if noframe:
                self.ax = self.fig.add_axes([0.0, 0.0, 1.0, 1.0])
            else:
                self.ax = self.fig.add_axes([0.07, 0.17, 0.9, 0.72])

            # prepare horizontal axis
            self.horizontal_coordinate = self.lat_inds[np.newaxis, :].repeat(
                self.data["air_pressure"].shape[0], axis=0)

            self._plot_style()

            # Set transparency for the output image.
            if transparent:
                self.fig.patch.set_alpha(0.)

            # Return the image as png embedded in a StringIO stream.
            canvas = FigureCanvas(self.fig)
            output = io.BytesIO()
            canvas.print_png(output)

            if show:
                logging.debug("saving figure to mpl_vsec.png ..")
                canvas.print_png("mpl_vsec.png")

            # Convert the image to an 8bit palette image with a significantly
            # smaller file size (~factor 4, from RGBA to one 8bit value, plus the
            # space to store the palette colours).
            # NOTE: PIL at the current time can only create an adaptive palette for
            # RGB images, hence alpha values are lost here. If transparency is
            # requested, the figure face colour is stored as the "transparent"
            # colour in the image. This works in most cases, but might lead to
            # visible artefacts in some cases.
            logging.debug("converting image to indexed palette.")
            # Read the above stored png into a PIL image and create an adaptive
            # colour palette.
            output.seek(0)  # necessary for PIL.Image.open()
            palette_img = PIL.Image.open(output).convert(mode="RGB").convert(
                "P", palette=PIL.Image.ADAPTIVE)
            output = io.BytesIO()
            if not transparent:
                logging.debug("saving figure as non-transparent PNG.")
                palette_img.save(
                    output,
                    format="PNG")  # using optimize=True doesn't change much
            else:
                # If the image has a transparent background, we need to find the
                # index of the background colour in the palette. See the
                # documentation for PIL's ImagePalette module
                # (http://www.pythonware.com/library/pil/handbook/imagepalette.htm). The
                # idea is to create a 256 pixel image with the same colour palette
                # as the original image and use it as a lookup-table. Converting the
                # lut image back to RGB gives us a list of all colours in the
                # palette. (Why doesn't PIL provide a method to directly access the
                # colours in a palette??)
                lut = palette_img.resize((256, 1))
                lut.putdata(list(range(256)))
                lut = [c[1] for c in lut.convert("RGB").getcolors()]
                facecolor_rgb = list(
                    mpl.colors.hex2color(mpl.colors.cnames[facecolor]))
                for i in [0, 1, 2]:
                    facecolor_rgb[i] = int(facecolor_rgb[i] * 255)
                facecolor_index = lut.index(tuple(facecolor_rgb))

                logging.debug(
                    "saving figure as transparent PNG with transparency index %i.",
                    facecolor_index)
                palette_img.save(output,
                                 format="PNG",
                                 transparency=facecolor_index)

            logging.debug("returning figure..")
            return output.getvalue()

        # Code for generating an XML document with the data values in ASCII format.
        # =========================================================================
        elif return_format == "text/xml":

            impl = getDOMImplementation()
            xmldoc = impl.createDocument(None, "MSS_VerticalSection_Data",
                                         None)

            # Title of this section.
            node = xmldoc.createElement("Title")
            node.appendChild(xmldoc.createTextNode(self.title))
            xmldoc.documentElement.appendChild(node)

            # Time information of this section.
            node = xmldoc.createElement("ValidTime")
            node.appendChild(
                xmldoc.createTextNode(
                    self.valid_time.strftime("%Y-%m-%dT%H:%M:%SZ")))
            xmldoc.documentElement.appendChild(node)

            node = xmldoc.createElement("InitTime")
            node.appendChild(
                xmldoc.createTextNode(
                    self.init_time.strftime("%Y-%m-%dT%H:%M:%SZ")))
            xmldoc.documentElement.appendChild(node)

            # Longitude data.
            node = xmldoc.createElement("Longitude")
            node.setAttribute("num_waypoints", f"{len(self.lons)}")

            data_str = ""
            for value in self.lons:
                data_str += str(value) + ","
            data_str = data_str[:-1]

            node.appendChild(xmldoc.createTextNode(data_str))
            xmldoc.documentElement.appendChild(node)

            # Latitude data.
            node = xmldoc.createElement("Latitude")
            node.setAttribute("num_waypoints", f"{len(self.lats)}")

            data_str = ""
            for value in self.lats:
                data_str += str(value) + ","
            data_str = data_str[:-1]

            node.appendChild(xmldoc.createTextNode(data_str))
            xmldoc.documentElement.appendChild(node)

            # Variable data.
            data_node = xmldoc.createElement("Data")

            for var in self.data:
                node = xmldoc.createElement(var)
                data_shape = self.data[var].shape
                node.setAttribute("num_levels", f"{data_shape[0]}")
                node.setAttribute("num_waypoints", f"{data_shape[1]}")

                data_str = ""
                for data_row in self.data[var]:
                    for value in data_row:
                        data_str += str(value) + ","
                    data_str = data_str[:-1] + "\n"
                data_str = data_str[:-1]

                node.appendChild(xmldoc.createTextNode(data_str))
                data_node.appendChild(node)

            xmldoc.documentElement.appendChild(data_node)

            # Return the XML document as formatted string.
            return xmldoc.toprettyxml(indent="  ")