Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions scripts/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ set(script_DIRS
v.db.univar
v.db.update
v.dissolve
v.geometry
v.import
v.in.e00
v.in.geonames
Expand Down
1 change: 1 addition & 0 deletions scripts/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ SUBDIRS = \
v.db.univar \
v.db.update \
v.dissolve \
v.geometry \
v.import \
v.in.e00 \
v.in.geonames \
Expand Down
7 changes: 7 additions & 0 deletions scripts/v.geometry/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
MODULE_TOPDIR = ../..

PGM = v.geometry

include $(MODULE_TOPDIR)/include/Make/Script.make

default: script
116 changes: 116 additions & 0 deletions scripts/v.geometry/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Fixtures for v.geometry tests."""

import io
import os

import pytest

import grass.script as gs
from grass.tools import Tools


LINE_ASCII = """\
ORGANIZATION: GRASS Test
DIGIT DATE: today
DIGIT NAME: test
MAP NAME: line
MAP DATE: today
MAP SCALE: 1
OTHER INFO:
ZONE: 0
MAP THRESH: 0.500000
VERTI:
L 2 1
0 0
100 100
1 1
"""


# Three non-overlapping areas (two sharing cat 2, one cat 1) plus a line
# with cat 10. Boundaries carry no categories, so v.to.db option=count
# emits spurious cat=-1 records for them. Used to check that aligning
# count to the other metric's cat set drops the line and the cat=-1
# records, and that counts for repeated cats aggregate correctly.
MIXED_ASCII = """\
ORGANIZATION: GRASS Test
DIGIT DATE: today
DIGIT NAME: test
MAP NAME: mixed
MAP DATE: today
MAP SCALE: 1
OTHER INFO:
ZONE: 0
MAP THRESH: 0.500000
VERTI:
B 5
0 0
50 0
50 50
0 50
0 0
C 1 1
25 25
1 1
B 5
60 0
100 0
100 50
60 50
60 0
C 1 1
80 25
1 2
B 5
0 60
50 60
50 100
0 100
0 60
C 1 1
25 80
1 2
L 2 1
70 70
90 90
1 10
"""


@pytest.fixture(scope="module")
def session(tmp_path_factory):
"""Session with a 2x2 rectangle grid, a set of points, and a straight line."""
tmp_path = tmp_path_factory.mktemp("v_geometry")
project = tmp_path / "test"
gs.create_project(project)
with gs.setup.init(project, env=os.environ.copy()) as grass_session:
tools = Tools(session=grass_session)
tools.g_region(s=0, n=100, w=0, e=100, res=1)

# 2x2 grid of 50x50 rectangles over (0,0)-(100,100).
tools.v_mkgrid(map="grid", grid=(2, 2))

# Three points at known coordinates.
points_ascii = "10|20\n30|40\n50|60\n"
tools.v_in_ascii(
input=io.StringIO(points_ascii),
output="points",
format="point",
separator="pipe",
)

# Single straight line from (0,0) to (100,100) with category 1.
tools.v_in_ascii(
input=io.StringIO(LINE_ASCII),
output="line",
format="standard",
)

# Mixed map: a line (cat 1) and a 50x50 area (cat 2).
tools.v_in_ascii(
input=io.StringIO(MIXED_ASCII),
output="mixed",
format="standard",
)

yield grass_session
167 changes: 167 additions & 0 deletions scripts/v.geometry/tests/v_geometry_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"""Tests for v.geometry."""

import math

import pytest

from grass.tools import Tools


def test_area_metrics(session):
"""Area-family metrics on a 2x2 grid of 50x50 squares.

Exercises area, perimeter, compactness, fractal_dimension, bbox in
one call so the merged-record and key-rename behaviors are both
covered without paying for extra subprocess spawns.
"""
tools = Tools(session=session)
result = tools.v_geometry(
map="grid", metric="area,perimeter,compactness,fractal_dimension,bbox"
)
assert len(result["records"]) == 4
expected_compactness = 200.0 / (2.0 * math.sqrt(math.pi * 2500.0))
for record in result["records"]:
assert record["area"] == pytest.approx(2500.0)
assert record["perimeter"] == pytest.approx(200.0)
assert record["compactness"] == pytest.approx(expected_compactness, rel=1e-6)
assert "compact" not in record
assert "fractal_dimension" in record
assert "fd" not in record
assert {"north", "south", "east", "west"} <= set(record.keys())
assert result["units"]["area"] == "square meters"
assert result["units"]["perimeter"] == "meters"


def test_area_hectares(session):
tools = Tools(session=session)
result = tools.v_geometry(map="grid", metric="area", units="hectares")
for record in result["records"]:
assert record["area"] == pytest.approx(0.25)
assert result["units"]["area"] == "hectares"


def test_line_metrics(session):
"""Line-family metrics on a straight line from (0,0) to (100,100)."""
tools = Tools(session=session)
result = tools.v_geometry(map="line", metric="length,sinuosity,azimuth")
record = result["records"][0]
assert record["length"] == pytest.approx(math.sqrt(2.0) * 100.0, rel=1e-6)
# A straight line has sinuosity 1.
assert record["sinuosity"] == pytest.approx(1.0, rel=1e-6)
assert "sinuous" not in record
assert "azimuth" in record


def test_count_totals(session):
tools = Tools(session=session)
result = tools.v_geometry(map="points", metric="count")
assert result["totals"]["count"] == 3


def test_coordinates(session):
tools = Tools(session=session)
result = tools.v_geometry(map="points", metric="coordinates")
coords = {(record["x"], record["y"]) for record in result["records"]}
assert coords == {(10.0, 20.0), (30.0, 40.0), (50.0, 60.0)}


def test_multiple_metrics_totals(session):
"""Totals from different metrics are merged."""
tools = Tools(session=session)
result = tools.v_geometry(map="grid", metric="area,count", type="centroid")
assert result["totals"]["area"] == pytest.approx(10000.0)
assert result["totals"]["count"] == 4


def test_per_metric_units(session):
"""Each metric gets its own unit via positional correspondence."""
tools = Tools(session=session)
result = tools.v_geometry(
map="grid", metric="area,perimeter", units="hectares,kilometers"
)
assert result["units"]["area"] == "hectares"
assert result["units"]["perimeter"] == "kilometers"
for record in result["records"]:
assert record["area"] == pytest.approx(0.25)
assert record["perimeter"] == pytest.approx(0.2)


def test_partial_units(session):
"""Fewer units than metrics: extra metrics use defaults."""
tools = Tools(session=session)
result = tools.v_geometry(map="grid", metric="area,perimeter", units="hectares")
assert result["units"]["area"] == "hectares"
assert result["units"]["perimeter"] == "meters"


def test_plain_format(session):
tools = Tools(session=session)
result = tools.v_geometry(map="grid", metric="area", format="plain")
lines = result.stdout.splitlines()
assert lines[0] == "category|area"
assert len(lines) == 5


def test_csv_format(session):
"""Also verifies that nprocs does not affect output."""
tools = Tools(session=session)
serial = tools.v_geometry(
map="grid",
metric="area,perimeter,compactness",
format="csv",
nprocs=1,
)
lines = serial.stdout.splitlines()
assert lines[0] == "category,area,perimeter,compactness"
assert len(lines) == 5
parallel = tools.v_geometry(
map="grid",
metric="area,perimeter,compactness",
format="csv",
nprocs=2,
)
assert serial.stdout == parallel.stdout


def test_csv_rejects_multichar_separator(session):
tools = Tools(session=session)
with pytest.raises(Exception, match="CSV separator"):
tools.v_geometry(map="grid", metric="area", format="csv", separator="--")


def test_mixed_feature_types_rejected(session):
"""Mixing metrics from different feature-type families fails cleanly."""
tools = Tools(session=session)
with pytest.raises(Exception, match="different feature types"):
tools.v_geometry(map="grid", metric="area,length")


def test_count_combined_aligns_with_family(session):
"""count combined with another metric filters to its cat set.

Mixed map features:
- Area cat 1 (50x50 = 2500): one feature.
- Area cat 2: two non-overlapping 40x50 features (total area 4000,
count 2) — verifies that repeated cats aggregate correctly.
- Line cat 10: must be dropped from count (not in area's cats).
- Three un-categorized boundaries: v.to.db option=count reports
them as cat=-1, which must also be dropped.

Expected after alignment:
- Records for cats 1 and 2 only, both carrying area and count.
- totals["count"] = 3 (1 for cat 1, 2 for cat 2), not polluted by
the line or the cat=-1 boundaries.
"""
tools = Tools(session=session)
result = tools.v_geometry(map="mixed", metric="area,count")

records_by_cat = {record["category"]: record for record in result["records"]}
assert set(records_by_cat) == {1, 2}

assert records_by_cat[1]["area"] == pytest.approx(2500.0)
assert records_by_cat[1]["count"] == 1
assert records_by_cat[2]["area"] == pytest.approx(4000.0)
assert records_by_cat[2]["count"] == 2

assert result["totals"]["area"] == pytest.approx(6500.0)
assert result["totals"]["count"] == 3
Loading
Loading