Commit ad751c5e authored by Rick Gruber-Riemer's avatar Rick Gruber-Riemer

Too much to tell. Amongst others worship buildings.

parent 82362866
......@@ -99,7 +99,7 @@ def process_scenery_tile(scenery_tile: SceneryTile, params_file_name: str, log_l
if exec_argument in [Procedures.pylons, Procedures.main, Procedures.all]:
pylons.process_pylons(the_coords_transform, my_fg_elev, my_stg_entries, file_lock)
if exec_argument in [Procedures.details, Procedures.all]:
pylons.process_details(the_coords_transform, my_fg_elev, my_stg_entries, file_lock)
pylons.process_details(the_coords_transform, my_fg_elev, file_lock)
platforms.process_details(the_coords_transform, my_fg_elev, file_lock)
piers.process_details(the_coords_transform, my_fg_elev, file_lock)
This diff is collapsed.
......@@ -611,15 +611,15 @@ def process_buildings(coords_transform: coordinates.Transformation, fg_elev: uti
the_buildings = building_lib.analyse(the_buildings, fg_elev,
prepare_textures.facades, prepare_textures.roofs, stats)
building_lib.decide_lod(the_buildings, stats)
# -- initialize STGManager
path_to_output = parameters.get_output_path()
replacement_prefix = parameters.get_repl_prefix()
stg_manager = stg_io2.STGManager(path_to_output, stg_io2.SceneryType.buildings, OUR_MAGIC, replacement_prefix)
the_buildings = building_lib.analyse(the_buildings, fg_elev, stg_manager, coords_transform,
prepare_textures.facades, prepare_textures.roofs, stats)
building_lib.decide_lod(the_buildings, stats)
# -- put buildings into clusters, decide LOD, shuffle to hide LOD borders
for b in the_buildings:
if b.LOD is stg_io2.LOD.detail:
......@@ -75,14 +75,16 @@ On Linux you would typically add something like the following to your ``.bashrc`
.. _chapter-set-fgroot-label:
Setting Environment Variable $FG_ROOT
Setting Operating System Environment Variable $FG_ROOT
The environment variable ``$FG_ROOT`` must be set in your operating system or at least your current session, such that ``fgelev`` can work optimally. How you set environment variables is depending on your operating system and not described here. I.e. this is NOT something you set as a parameter in ``params.ini``!
You might have to restart Windows to be able to read the environment variable that you set through the control panel. In Linux you might have to create a new console session.
`$FG_ROOT`_ is typically a path ending with directories ``data`` or ``fgdata`` (e.g. on Linux it could be ``/home/pingu/bin/fgfs_git/next/install/flightgear/fgdata``).
`$FG_ROOT`_ is typically a path ending with directories ``data`` or ``fgdata`` (e.g. on Linux it could be ``/home/pingu/bin/fgfs_git/next/install/flightgear/fgdata``; on Windows it might be ``C:\flightGear\2017.3.1\data``).
BTW: you have to set the name of the variable in your operating system to ``FG_ROOT`` (not ``$FG_ROOT``).
......@@ -71,17 +71,13 @@ PATH_TO_OSM2CITY_DATA Path n/a Full path t
:ref:`Installation of osm2city <chapter-osm2city-install>` (e.g.
NO_ELEV Boolean False Set this to ``False``. The only reason to set this to ``True`` would be for
builders to check generated scenery objects a bit faster not caring about
the vertical position in the scenery.
FG_ELEV String n/a Points to the full path of the fgelev executable. On Linux it could be
something like ``.../bin/fgfs_git/next/install/flightgear/bin/fgelev'``.
On Windows you might have to put quotes around the path due to whitespace
e.g. ``'"D:/Program Files/FlightGear/bin/Win64/fgelev.exe"'``.
PROBE_FOR_WATER Boolean False Checks the scenery in ``PATH_TO_SCENERY`` whether points are in the water or
not. The Flightgear scenery's water boundaries might be different from OSM.
not. The FlightGear scenery's water boundaries might be different from OSM.
E.g. removes buildings if at least one corner is in the water. And removes
or splits (parts of) roads/railways, if at least 1 point is in the water.
Only possible with FGElev version after 9th of November 2016 / FG 2016.4.1.
......@@ -103,6 +99,19 @@ Parameter Type Default Description
FLAG_2017_2 Boolean True If True then the textures and effects for osm2city built into FlightGear will
be used. Otherwise each scenery sub-folder will have a folder with textures.
BUILDING_USE_SHARED_WORSHIP Boolean False Use a shared model for worship buildings instead of OSM floor plan and
heuristics. The shared models will try to respect the type of building (e.g.
church vs. mosque) and will in size (not height) respect the convex hull of
the building floor plan in OSM.
If the building is composed of several parts as in OSM 3D buildings, then no
shared models are used - as it is assumed that the 3D modeling is more
realistic (e.g number and height of towers) than a generic model, although
the facade texture is more dumb than a typical shared model texture.
NO_ELEV Boolean False The only reason to set this to ``True`` would be for scenery builders to
check generated scenery objects a bit faster not caring about the vertical
position in the scenery.
============================================= ======== ======= ==============================================================================
'Tool to download inclusive backing off when receiving 429
import argparse
import logging
import re
from subprocess import PIPE
import subprocess
import sys
from time import sleep
import parameters
if __name__ == '__main__':
parser = argparse.ArgumentParser(description="Downloads a tile from osm. It handles too many requests and backs off")
parser.add_argument("-f", "--properties", dest="properties",
help="The name of the property file to be copied", required=True)
parser.add_argument("-l", "--loglevel"
, help="set loglevel. Valid levels are VERBOSE, DEBUG, INFO, WARNING, ERROR, CRITICAL"
, required=False)
args = parser.parse_args()
if is not None:
parameters.set_loglevel(args.loglevel) # -- must go after reading params file
for x in range(0, 10):
download_command = 'curl -w %s -f --proxy-ntlm -o %s/buildings.osm,%f,%f,%f'
path = '%s/buildings.osm' % parameters.PREFIX
url = ',%f,%f,%f' % (parameters.BOUNDARY_WEST
, parameters.BOUNDARY_SOUTH
, parameters.BOUNDARY_EAST
, parameters.BOUNDARY_NORTH)
# if parameters.BASH_PARALLEL_PROCESS :
# download_command += '&' + os.linesep + 'parallel_wait $max_parallel_process' + os.linesep
# else :
# download_command += os.linesep
# print download_command % (parameters.PREFIX, parameters.BOUNDARY_WEST, parameters.BOUNDARY_SOUTH, parameters.BOUNDARY_EAST, parameters.BOUNDARY_NORTH)"Downloading %s", parameters.PREFIX)
tries = 0
download_command = download_command % ("CODE:%{http_code}:", parameters.PREFIX, parameters.BOUNDARY_WEST
, parameters.BOUNDARY_SOUTH, parameters.BOUNDARY_EAST
, parameters.BOUNDARY_NORTH)
while tries < 10:
proc = subprocess.Popen(download_command, stderr=PIPE, stdout=PIPE, bufsize=1, universal_newlines=True)
# exitcode = proc.wait()
outlines = ""
with proc.stderr:
for line in iter(proc.stderr.readline, b''):
outlines += line
# Already read stderr setting to None lets us get stdout
proc.stderr = None
output = proc.communicate()[0]
exitcode = proc.wait() # wait for the subprocess to exit http_code ="CODE:([0-9]*):", outs).group(1)
http_code ="CODE:([0-9]*):", output).group(1)"Received %s", http_code)
if http_code != "429":
if http_code == "200":"Downloaded successfully %s", http_code)
logging.error("Non repeatable http_code %s", http_code)
tries += 1
wait = 60 * tries"Received too many requests retrying in %d s %d", wait, tries)
sleep(wait)"Too many requests failing with %s", http_code)
......@@ -96,6 +96,7 @@ OVERLAP_CHECK_BRIDGE_MIN_REMAINING = 10
# a static model for these, and the overlap check fails.
# Use unicode strings as in the first example if there are non-ASCII characters.
# E.g. SKIP_LIST = ["Theologische Fakultät", "Rhombergpassage", 55875208]
# For roads/railways OSM ID is checked.
# -- Parameters which influence the number of buildings from OSM taken to output
......@@ -147,6 +148,8 @@ BUILDING_CITY_LEVEL_HEIGHT_MODE = 3.3
# FIXME: same parameters for place = town, village, suburb
BUILDING_USE_SHARED_WORSHIP = False # try to use shared models for worship buildings
# -- The more buildings end up in LOD rough, the more work for your GPU.
# Increasing any of the following parameters will decrease GPU load.
LOD_ALWAYS_DETAIL_BELOW_AREA = 150 # -- below this area, buildings will always be LOD detail
......@@ -250,7 +253,7 @@ MAX_SLOPE_MOTORWAY = 0.03 # max slope for motorways
BRIDGE_MIN_LENGTH = 20. # discard short bridges, draw road instead
CREATE_BRIDGES_ONLY = 0 # create only bridges and embankments
BRIDGE_LAYER_HEIGHT = 4. # bridge height per layer
BRIDGE_BODY_HEIGHT = 0.9 # height of bridge body
......@@ -261,7 +264,7 @@ HIGHWAY_TYPE_MIN_ROUGH_LOD = 6 # the minimum type tobe added to the rough LOD c
POINTS_ON_LINE_DISTANCE_MAX = 1000 # the maximum distance between two points on a line. If longer, then new points are added
BUILT_UP_AREA_LIT_BUFFER = 20 # the buffer around built-up land-use areas to be used for lighting of streets
USE_TRAM_LINES = False # whether to build tram lines (OSM railway=tram) often they do not merge well with roads
USE_TRAM_LINES = False # whether to build tram lines (OSM railway=tram). Often they do not merge well with roads
# =============================================================================
......@@ -154,7 +154,7 @@ def _write_boat_line(pier, stg_manager, coords_transform: coordinates.Transforma
def _write_model(length, stg_manager, pos_global, direction, my_elev):
def _write_model(length, stg_manager: stg_io2.STGManager, pos_global, direction, my_elev) -> None:
if length < 20:
models = [('Models/Maritime/Civilian/', 120),
('Models/Maritime/Civilian/', 120),
......@@ -299,13 +299,12 @@ class SharedPylon(object):
self.lon, = my_coord_transformator.toGlobal((self.x, self.y))
self.elevation = fg_elev.probe_elev(vec2d.Vec2d(self.lon,, True)
def make_stg_entry(self, my_stg_mgr):
Returns a stg entry for this pylon.
def make_stg_entry(self, my_stg_mgr: stg_io2.STGManager) -> None:
"""Returns a stg entry for this pylon.
E.g. OBJECT_SHARED Models/Airport/ils.xml 5.313108 45.364122 374.49 268.92
if not self.needs_stg_entry:
return " " # no need to write a shared object
return # no need to write a shared object
direction_correction = 0
if self.direction_type is PylonDirectionType.mirror:
......@@ -452,7 +451,7 @@ class StorageTank(SharedPylon):
self.elevation -= 62
self.pylon_model = 'Models/Industrial/' + self.pylon_model
def make_stg_entry(self, my_stg_mgr) -> None:
def make_stg_entry(self, my_stg_mgr: stg_io2.STGManager) -> None:
my_stg_mgr.add_object_shared(self.pylon_model, vec2d.Vec2d(self.lon,, self.elevation, 0)
......@@ -537,7 +536,7 @@ class WindTurbine(SharedPylon):
logging.debug("Wind turbine shared model chosen: {}".format(shared_model))
return common_path + shared_model
def make_stg_entry(self, my_stg_mgr) -> None:
def make_stg_entry(self, my_stg_mgr: stg_io2.STGManager) -> None:
# special for Vestas_Off_Shore140M.xml
if self.pylon_model.endswith("140M.xml"):
my_stg_mgr.add_object_shared("Models/Power/", vec2d.Vec2d(self.lon,,
......@@ -1519,7 +1518,7 @@ def _distribute_way_segments_to_clusters(lines: List[Line], cluster_container: c
def _write_cable_clusters(cluster_container: cluster.ClusterContainer, coords_transform: coordinates.Transformation,
my_stg_mgr: stg_io2.STGManager) -> None:
my_stg_mgr: stg_io2.STGManager, details: bool=False) -> None:
cluster_index = 0
for ic, cl in enumerate(cluster_container):
cluster_index += 1
......@@ -1546,7 +1545,14 @@ def _write_cable_clusters(cluster_container: cluster.ClusterContainer, coords_tr
cluster_y = y_max - (y_max - y_min)/2.0
cluster_elevation = elevation_max - (elevation_max - elevation_min)/2.0
center_global = coords_transform.toGlobal((cluster_x, cluster_y))
cluster_filename = parameters.get_repl_prefix() + "cables%05d" % cluster_index
cluster_filename = parameters.get_repl_prefix()
# it is important to have the ac-file names for cables different in "Pylons" and "Details/Objects",
# because otherwise FG does not know which information to take from which stg-files, which results
# in that e.g. the ac-file is taken from the Pylons stg - but the lat/lon/angle from the
# "Details/Objects" stg-file.
if details:
cluster_filename += 'd'
cluster_filename += 'cables%05d' % cluster_index
path_to_stg = my_stg_mgr.add_object_static(cluster_filename + '.ac', vec2d.Vec2d(center_global[0],
cluster_elevation, 90, cluster_container.stg_verb_type)
......@@ -1855,7 +1861,7 @@ def process_pylons(coords_transform: coordinates.Transformation, fg_elev: utilit
def process_details(coords_transform: coordinates.Transformation, fg_elev: utilities.FGElev,
stg_entries: List[stg_io2.STGEntry], file_lock: mp.Lock=None) -> None:
file_lock: mp.Lock=None) -> None:
# Transform to real objects"Transforming OSM data to Line and Pylon objects -> details")
......@@ -1872,12 +1878,12 @@ def process_details(coords_transform: coordinates.Transformation, fg_elev: utili
# Minor power lines and aerialways
powerlines = list()
aerialways = list()
req_keys = list()
req_keys = list()
if req_keys:
osm_way_result = osmparser.fetch_osm_db_data_ways_keys(req_keys)
osm_nodes_dict = osm_way_result.nodes_dict
osm_ways_dict = osm_way_result.ways_dict
......@@ -1933,7 +1939,7 @@ def process_details(coords_transform: coordinates.Transformation, fg_elev: utili
stg_manager = stg_io2.STGManager(path_to_output, stg_io2.SceneryType.details, OUT_MAGIC_DETAILS,
# Write to Flightgear
# Write to FlightGear
cmin, cmax = parameters.get_extent_global()"min/max " + str(cmin) + " " + str(cmax))
lmin = vec2d.Vec2d(coords_transform.toLocal(cmin))
......@@ -1950,7 +1956,7 @@ def process_details(coords_transform: coordinates.Transformation, fg_elev: utili
_distribute_way_segments_to_clusters(rail_lines, cluster_container)
_write_stg_entries_pylons_for_line(stg_manager, rail_lines)
_write_cable_clusters(cluster_container, coords_transform, stg_manager)
_write_cable_clusters(cluster_container, coords_transform, stg_manager, details=True)
_write_stg_entries_pylons_for_line(stg_manager, streetlamp_ways)
......@@ -39,7 +39,7 @@ class OSMElement(object):
def add_tag(self, key: str, value: str) -> None:
self.tags[key] = value
def __str__(self):
def __str__(self) -> str:
return "<%s OSM_ID %i at %s>" % (type(self).__name__, self.osm_id, hex(id(self)))
"""Utilities to plot for visual debugging purposes to pdf-files using matplotlib."""
import datetime
from typing import Tuple
from matplotlib import axes as maxs
from matplotlib import figure as mfig
from matplotlib import pyplot as plt
from matplotlib.backends.backend_pdf import PdfPages
def create_a4_landscape_figure() -> mfig.Figure:
return plt.figure(figsize=(8.27, 11.69), dpi=600)
def create_pdf_pages(title_part: str) -> PdfPages:
today =
date_string = today.strftime("%Y-%m-%d_%H%M%S")
return PdfPages("osm2city_debug_{0}_{1}.pdf".format(title_part, date_string))
def set_ax_limits_bounds(ax: maxs.Axes, bounds: Tuple[float, float, float, float]) -> None:
set_ax_limits(ax, bounds[0], bounds[1], bounds[2], bounds[3])
def set_ax_limits(ax: maxs.Axes, x_min: float, y_min: float, x_max: float, y_max: float) -> None:
w = x_max - x_min
h = y_max - y_min
ax.set_xlim(x_min - 0.2*w, x_max + 0.2*w)
ax.set_ylim(y_min - 0.2*h, y_max + 0.2*h)
......@@ -183,16 +183,16 @@ class STGManager(object):
self.stg_dict[tile_index] = the_stg_file
return the_stg_file
def add_object_static(self, ac_file_name, lon_lat: Vec2d, elev, hdg,
stg_verb_type: STGVerbType=STGVerbType.object_static, once=False):
def add_object_static(self, ac_file_name: str, lon_lat: Vec2d, elev: float, hdg: float,
stg_verb_type: STGVerbType=STGVerbType.object_static, once=False) -> str:
"""Adds OBJECT_STATIC line. Returns path to stg."""
the_stg_file = self._find_create_stg_file(lon_lat)
return the_stg_file.add_object(, ac_file_name, lon_lat, elev, hdg, once)
def add_object_shared(self, ac_file_name, lon_lat, elev, hdg):
"""Adds OBJECT_SHARED line. Returns path to stg it was added to."""
def add_object_shared(self, ac_file_name: str, lon_lat: Vec2d, elev:float , hdg: float) -> None:
"""Adds OBJECT_SHARED line."""
the_stg_file = self._find_create_stg_file(lon_lat)
return the_stg_file.add_object('OBJECT_SHARED', ac_file_name, lon_lat, elev, hdg)
the_stg_file.add_object('OBJECT_SHARED', ac_file_name, lon_lat, elev, hdg)
def write(self, file_lock: mp.Lock=None):
"""Writes all new scenery objects including the already existing back to stg-files.
......@@ -16,6 +16,8 @@ from typing import Dict, List, Optional, Tuple
import unittest
import numpy as np
from shapely import affinity
import shapely.geometry as shg
import parameters
from utils import coordinates, osmparser
......@@ -437,6 +439,27 @@ class FGElev(object):
return elev_is_solid_tuple
def probe_list_of_points(self, points: List[Tuple[float, float]]) -> float:
"""Get the elevation of the node lowest node of a list of points.
If a node is in water or at -9999, then return -9999
elev_water_ok = True
temp_ground_elev = 9999
for point in points:
elev_is_solid_tuple = self.probe(ve.Vec2d(point))
if elev_is_solid_tuple[0] == -9999:
elev_water_ok = False
elif not elev_is_solid_tuple[1]:
logging.debug("in water")
elev_water_ok = False
temp_ground_elev = min([temp_ground_elev, elev_is_solid_tuple[0]]) # we are looking for the lowest value
if not elev_water_ok:
return -9999
return temp_ground_elev
def progress(i, max_i):
"""progress indicator"""
......@@ -492,6 +515,170 @@ def check_boundary(boundary_west: float, boundary_south: float,
def bounds_from_list(bounds_list: List[Tuple[float, float, float, float]]) -> Tuple[float, float, float, float]:
"""Finds the bounds (min_x, min_y, max_x, max_y) from a list of bounds.
If the list of bounds is None or empty, then (0,0,0,0) is returned."""
if not bounds_list:
return 0, 0, 0, 0
min_x = sys.float_info.max
min_y = sys.float_info.max
max_x = sys.float_info.min
max_y = sys.float_info.min
for bounds in bounds_list:
min_x = min(min_x, bounds[0])
min_y = min(min_y, bounds[1])
max_x = max(max_x, bounds[2])
max_y = max(max_y, bounds[3])
return min_x, min_y, max_x, max_y
def minimum_circumference_rectangle_for_polygon(hull: shg.Polygon) -> Tuple[float, float, float]:
"""Constructs a minimum circumference rectangle around a polygon and returns its angle, length and width
There is no check whether length is longer than width - or that length is closer to e.g. the x-axis.
This is different from a bounding box, which just uses min/max along axis.
Circumference is used as opposed to typically area because often buildings tend to be less quadratic.
The general idea is that at least one edge of the polygon will be aligned with an edge of the rectangle.
Therefore Go through all edges of the polygon, rotate it down to normal axis, create a bounding box and
save the dimensions incl. angle. Then compare with others obtained.
Often the polygon is a convex hull for points. In osm2city it might be the convex hull of a building.
A different algorithm also discussed in the article referenced above is using m matrix multiplication instead
of trigonometrics.
For an overview see also: David Eberly, 2015: Minimum-Area Rectangle Containing A Set of Points.
min_angle = 0.
min_length = 0.
min_width = 0.
min_circumference = 99999999.
hull_coords = hull.exterior.coords[:] # list of x,y tuples
for index in range(len(hull_coords) - 1):
angle = coordinates.calc_angle_of_line_local(hull_coords[index][0], hull_coords[index][1],
hull_coords[index + 1][0], hull_coords[index + 1][1])
rotated_hull = affinity.rotate(hull, - angle, (0, 0))
bounding_box = rotated_hull.bounds # tuple x_min, y_min, x_max, y_max
bb_length = math.fabs(bounding_box[2] - bounding_box[0])
bb_width = math.fabs(bounding_box[3] - bounding_box[1])
circumference = 2 * (bb_length + bb_width)
if circumference < min_circumference:
min_angle = angle
if bb_length >= bb_width:
min_length = bb_length
min_width = bb_width
min_angle += 90 # it happens to be such that the angle is against the y-axis
min_length = bb_width
min_width = bb_length
min_circumference = circumference
return min_angle, min_length, min_width
def fit_offsets_for_rectangle_with_hull(angle: float, hull: shg.Polygon, model_length: float, model_width: float,
model_length_offset: float, model_width_offset: float,
model_length_largest: bool,
model_name: str, osm_id: int) -> Tuple[float, float]:
"""Makes sure that a rectangle (bounding box) on a convex hull fits as good as possible and returns centroid.
This is necessary because the angle out of function minimum_circumference_rectangle_for_polygon(...) cannot be
known whether it should have been +/- 180 degrees (depends on which point in hull gets started with at least
if the hull was a rectangle to begin with).
NB: length_largest could also be calculated on the fly, but is chosen to be consistent with caller in building_lib.
# if both the length and width offsets are null, then the centroid will always be the hull's centroid
if model_length_offset == 0 and model_width_offset == 0:
return hull.centroid.x, hull.centroid.y
# need to correct the offsets based on whether the model has longer length or width
my_length = model_length
my_width = model_width
my_length_offset = model_length_offset
my_width_offset = model_width_offset
if not model_length_largest:
my_length = model_width
my_width = model_length
my_length_offset = model_width_offset
my_width_offset = model_length_offset
box =, -my_width/2, my_length/2, my_width/2)
box = affinity.rotate(box, angle)
box = affinity.translate(box, hull.centroid.x, hull.centroid.y)
# need to correct along x-axis and y-axis due to offsets in the ac-model
correction_x = math.sin(angle) * my_length_offset
correction_x += math.cos(angle) * my_width_offset
correction_y = math.cos(angle) * my_length_offset
correction_y += math.sin(angle) * my_width_offset
box_minus = affinity.translate(box, -correction_x, -correction_y)
difference_minus = box_minus.difference(hull)
box_plus = affinity.translate(box, correction_x, correction_y)
difference_plus = box_plus.difference(hull)
new_x = hull.centroid.x - correction_x
new_y = hull.centroid.y - correction_y
if difference_minus.area > difference_plus.area:
new_x = hull.centroid.x + correction_x
new_y = hull.centroid.y + correction_y
if parameters.DEBUG_PLOT:
plot_fit_offsets(hull, box_minus, box_plus, angle, model_length_largest,
new_x, new_y, model_name, osm_id)
return new_x, new_y
# ================ PLOTTING FOR VISUAL TESTING =====
import utils.plot_utilities as pu
from descartes import PolygonPatch
from matplotlib import patches as pat
from time import sleep
def plot_fit_offsets(hull: shg.Polygon, box_minus: shg.Polygon, box_plus: shg.Polygon,
angle: float,
model_length_largest: bool,
centroid_x: float, centroid_y: float,
model_name: str, osm_id: int) -> None:
pdf_pages = pu.create_pdf_pages(str(osm_id))
my_figure = pu.create_a4_landscape_figure()
title = 'osm_id={},\n model={},\n angle={},\n length_largest={}'.format(osm_id, model_name, angle,
ax = my_figure.add_subplot(111)
patch = PolygonPatch(hull, facecolor='none', edgecolor="black")
patch = PolygonPatch(box_minus, facecolor='none', edgecolor="green")
patch = PolygonPatch(box_plus, facecolor='none', edgecolor="red")
ax.add_patch(pat.Circle((centroid_x, centroid_y), radius=0.4, linewidth=2,
color='blue', fill=False))
bounds = bounds_from_list([box_minus.bounds, box_plus.bounds])
pu.set_ax_limits_bounds(ax, bounds)
sleep(2) # to make sure we have not several files in same second
# ================ UNITTESTS =======================
class TestUtilities(unittest.TestCase):
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment