Skip to content

Commit 066ad27

Browse files
committed
Fix matplotlib.cm.get_cmap removal (use plt.get_cmap); pin exact angles, tighten landmarks 2dvis; pump osm->96% and visibility3d->89% coverage
1 parent 734d21b commit 066ad27

5 files changed

Lines changed: 142 additions & 10 deletions

File tree

cityImage/plotting/static.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -946,7 +946,7 @@ def _generate_legend_fig(ax, plot, gdf, unique_categories=None, cmap=None):
946946
labels = []
947947
geometry_type = gdf.iloc[0].geometry.geom_type
948948
for i, cat in enumerate(unique_categories):
949-
color = cm.get_cmap(cmap)(i / len(unique_categories))
949+
color = plt.get_cmap(cmap)(i / len(unique_categories))
950950
if geometry_type in ["Polygon", "MultiPolygon"]:
951951
patch = mpatches.Patch(color=color, label=str(cat))
952952
elif geometry_type in ["LineString", "MultiLineString"]:

tests/test_angles_orientations.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,19 @@
2727
@pytest.mark.parametrize("calc", ["vectors", "angular_change", "deflection"])
2828
@pytest.mark.parametrize("name", list(ORIENTATIONS))
2929
def test_angle_line_geometries_handles_every_endpoint_orientation(name, calc):
30+
# LINE_A is vertical and every B geometry is horizontal, so the two lines are
31+
# perpendicular in every orientation and calculation type: the exact answer is 90 degrees.
3032
angle = ci.angle_line_geometries(LINE_A, ORIENTATIONS[name], degree=True, calculation_type=calc)
31-
assert 0.0 <= angle < 360.0
33+
assert angle == pytest.approx(90.0)
3234

3335

34-
def test_angle_line_geometries_perpendicular_is_90_degrees():
35-
# A points up, B points right; they meet at (0, 0).
36-
angle = ci.angle_line_geometries(
37-
LINE_A, ORIENTATIONS["end_a_eq_start_b"], degree=True, calculation_type="vectors"
38-
)
39-
assert angle == pytest.approx(90.0)
36+
def test_angle_line_geometries_collinear_lines_are_180_degrees():
37+
# Two straight, co-linear segments meeting end-to-start read as a 180-degree (straight) turn,
38+
# proving the function is not simply returning 90 for everything.
39+
up_a = LineString([(0, 0), (0, 10)])
40+
up_b = LineString([(0, 10), (0, 20)])
41+
angle = ci.angle_line_geometries(up_a, up_b, degree=True, calculation_type="vectors")
42+
assert angle == pytest.approx(180.0)
4043

4144

4245
def test_angle_line_geometries_rejects_non_linestrings():

tests/test_landmarks_scores.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def test_structural_score_computes_distance_visibility_and_neighbours():
6969
out = ci.structural_score(buildings, obstructions_gdf=None, edges_gdf=edges)
7070
assert (out["road"] > 0).all() # all buildings sit above the street line
7171
assert (out["neigh"] >= 1).all() # a building is its own neighbour at minimum
72-
assert out["2dvis"].notna().all()
72+
assert (out["2dvis"] > 0).all() # each building has a non-degenerate advance-visibility area
7373

7474

7575
def test_visibility_score_uses_sight_lines_and_height():

tests/test_osm_mocked.py

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@
1010
import types
1111

1212
import geopandas as gpd
13+
import pandas as pd
1314
import pytest
14-
from shapely.geometry import Polygon
15+
from shapely.geometry import LineString, Point, Polygon
1516

1617
import cityImage.osm as osm
1718
from cityImage.osm import (
@@ -156,3 +157,102 @@ def test_network_from_osm_validates_arguments(monkeypatch):
156157
network_from_osm("X", download_method="bogus")
157158
with pytest.raises(ValueError, match="distance is required"):
158159
network_from_osm("X", download_method="distance_from_address")
160+
161+
162+
# --------------------------------------------------------------------------- graph conversion
163+
164+
165+
def _fake_network_ox():
166+
"""Fake OSMnx exposing a graph pipeline over a tiny 3-node / 2-edge network."""
167+
nodes = gpd.GeoDataFrame(
168+
{"x": [0.0, 10.0, 20.0], "y": [0.0, 0.0, 10.0]},
169+
geometry=[Point(0, 0), Point(10, 0), Point(20, 10)],
170+
index=[10, 20, 30], # OSM node ids
171+
crs=UTM,
172+
)
173+
edge_idx = pd.MultiIndex.from_tuples([(10, 20, 0), (20, 30, 0)], names=["u", "v", "key"])
174+
edges = gpd.GeoDataFrame(
175+
{"length": [10.0, 14.14], "highway": ["residential", "primary"], "name": ["A", "B"]},
176+
geometry=[LineString([(0, 0), (10, 0)]), LineString([(10, 0), (20, 10)])],
177+
index=edge_idx,
178+
crs=UTM,
179+
)
180+
graph = types.SimpleNamespace(graph={"crs": UTM})
181+
182+
def graph_to_gdfs(g, nodes=False, edges=False, **_):
183+
return nodes_frame.copy() if nodes else edges_frame.copy()
184+
185+
nodes_frame, edges_frame = nodes, edges
186+
187+
def graph_from(*_a, **_k):
188+
return graph
189+
190+
return types.SimpleNamespace(
191+
graph_from_place=graph_from,
192+
graph_from_address=graph_from,
193+
graph_from_point=graph_from,
194+
graph_from_polygon=graph_from,
195+
project_graph=lambda g, to_crs=None: g,
196+
graph_to_gdfs=graph_to_gdfs,
197+
projection=types.SimpleNamespace(project_gdf=lambda gdf, **_: gdf),
198+
features_from_place=lambda q, tags=None: _building_features(tags),
199+
)
200+
201+
202+
def test_network_from_osm_converts_graph_to_cityimage_schema(monkeypatch):
203+
monkeypatch.setattr(osm, "ox", _fake_network_ox())
204+
nodes, edges = network_from_osm("Place", crs=UTM, dict_columns={"road_type": "highway"})
205+
206+
assert {"nodeID", "x", "y", "geometry"}.issubset(nodes.columns)
207+
assert {"u", "v", "edgeID", "length", "road_type"}.issubset(edges.columns)
208+
node_ids = set(nodes["nodeID"])
209+
assert set(edges["u"]).issubset(node_ids) and set(edges["v"]).issubset(node_ids)
210+
assert edges["road_type"].tolist() == ["residential", "primary"] # dict_columns mapping applied
211+
212+
213+
@pytest.mark.parametrize(
214+
"download_method,query,distance",
215+
[
216+
("distance_from_address", "Addr", 500),
217+
("distance_from_point", (0.0, 0.0), 500),
218+
("polygon", Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]), None),
219+
],
220+
)
221+
def test_network_from_osm_dispatches_each_graph_method(
222+
monkeypatch, download_method, query, distance
223+
):
224+
monkeypatch.setattr(osm, "ox", _fake_network_ox())
225+
nodes, edges = network_from_osm(
226+
query, crs=UTM, download_method=download_method, distance=distance
227+
)
228+
assert not nodes.empty and not edges.empty
229+
230+
231+
def test_network_from_osm_dict_columns_missing_column_raises(monkeypatch):
232+
monkeypatch.setattr(osm, "ox", _fake_network_ox())
233+
with pytest.raises(ValueError, match="missing column"):
234+
network_from_osm("Place", crs=UTM, dict_columns={"road_type": "does_not_exist"})
235+
236+
237+
def test_buildings_from_osm_projects_when_crs_is_none(monkeypatch):
238+
monkeypatch.setattr(osm, "ox", _fake_network_ox())
239+
buildings = buildings_from_osm(
240+
"Place", crs=None, min_area=200
241+
) # triggers projection.project_gdf
242+
assert "buildingID" in buildings.columns and len(buildings) == 1
243+
244+
245+
def test_buildings_from_osm_drops_below_min_area(monkeypatch):
246+
def _two_buildings(tags=None, **_):
247+
return gpd.GeoDataFrame(
248+
{"building": ["yes", "yes"]},
249+
geometry=[
250+
Polygon([(0, 0), (20, 0), (20, 20), (0, 20)]), # 400 m^2 -> kept
251+
Polygon([(100, 100), (101, 100), (101, 101), (100, 101)]), # 1 m^2 -> dropped
252+
],
253+
crs=UTM,
254+
)
255+
256+
monkeypatch.setattr(osm, "ox", _fake_ox(_two_buildings))
257+
buildings = buildings_from_osm("Place", crs=UTM, min_area=200)
258+
assert len(buildings) == 1 # the 1 m^2 footprint is filtered out

tests/test_visibility3d_pipeline.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,35 @@ def test_compute_3d_sight_lines_with_consolidation(monkeypatch, tmp_path):
9999
assert out.geometry.iloc[0].has_z
100100

101101

102+
def test_compute_3d_sight_lines_with_simplified_targets(monkeypatch, tmp_path):
103+
monkeypatch.chdir(tmp_path)
104+
buildings = _buildings()
105+
# A single simplified outline enclosing both detailed target buildings, which routes the
106+
# pipeline through _use_simplified_buildings (detailed targets mapped onto simplified geometry).
107+
simplified = gpd.GeoDataFrame(
108+
{"simplifiedID": [1]},
109+
geometry=[Polygon([(-1, -1), (11, -1), (11, 31), (-1, 31)])],
110+
crs=CRS,
111+
)
112+
113+
out = ci.compute_3d_sight_lines(
114+
nodes_gdf=_nodes(),
115+
target_buildings_gdf=buildings.copy(),
116+
obstructions_buildings_gdf=buildings.copy(),
117+
simplified_target_buildings=simplified,
118+
edges_gdf=_edges(),
119+
city_name="TestSimplified",
120+
distance_along=5,
121+
min_observer_target_distance=100,
122+
num_workers=1,
123+
)
124+
125+
assert isinstance(out, gpd.GeoDataFrame)
126+
assert len(out) > 0
127+
assert (out.geometry.geom_type == "LineString").all()
128+
assert out.geometry.iloc[0].has_z
129+
130+
102131
def test_compute_3d_sight_lines_no_visible_returns_empty(monkeypatch, tmp_path):
103132
monkeypatch.chdir(tmp_path)
104133
buildings = _buildings()

0 commit comments

Comments
 (0)