Adds station_from_distance_along to alignment api

This commit is contained in:
Richard Brice
2026-09-10 16:14:29 -07:00
parent 243f0f32dc
commit d9864be64a
3 changed files with 681 additions and 0 deletions
@@ -63,6 +63,7 @@ from .create_layout_segment import create_layout_segment
from .create_representation import create_representation
from .create_segment_representations import create_segment_representations
from .distance_along_from_station import distance_along_from_station
from .station_from_distance_along import station_from_distance_along
from .get_alignment import get_alignment
from .get_alignment_layout_nest import get_alignment_layout_nest
from .get_alignment_layouts import get_alignment_layouts
@@ -111,6 +112,7 @@ __all__ = [
"create_representation",
"create_segment_representations",
"distance_along_from_station",
"station_from_distance_along",
"get_alignment",
"get_alignment_layout_nest",
"get_alignment_layouts",
@@ -0,0 +1,102 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell
import ifcopenshell.api.alignment
import ifcopenshell.util.element
from ifcopenshell import entity_instance
from ifcopenshell.api.alignment._referent_distance_along import _referent_distance_along
def station_from_distance_along(file: ifcopenshell.file, alignment: entity_instance, dist_along: float) -> float:
"""
Given a distance along a horizontal alignment, returns the corresponding station value.
This is the inverse of distance_along_from_station(). Every distance along the alignment maps
to exactly one station, so this function always returns a float (never None).
If the alignment does not have stationing defined with an IfcReferent, the start of the
alignment is taken from get_alignment_start_station() (or 0.0 if none is defined), and the
station is simply start_station + dist_along.
Station equations (where Pset_Stationing.IncomingStation is set on a referent) and reverse
(decreasing) stationing (where Pset_Stationing.HasIncreasingStation is False) are taken into
account using the same referent walk as distance_along_from_station():
For each STATION referent nested to the alignment, DistanceAlong (D) and the outgoing station
(S, i.e. Pset_Stationing.Station) are read off and sorted by DistanceAlong. A direction sign
is tracked while walking the sorted referents: it starts at +1 and is set to +1 or -1 at any
referent that carries an explicit Pset_Stationing.HasIncreasingStation. The governing referent
is the last one whose DistanceAlong does not exceed dist_along, and the station is:
station = S + sigma * (dist_along - D)
If dist_along precedes the first referent's DistanceAlong, the station is extrapolated
backward from the first referent using the same formula.
:param file: the IFC file
:param alignment: the alignment entity
:param dist_along: distance along the horizontal alignment
:return: the station value at the given distance along
Example:
.. code:: python
alignment = model.by_type("IfcAlignment")[0] # alignment with start station 1+00.00
station = ifcopenshell.api.alignment.station_from_distance_along(model, alignment, 200.0)
print(station) # 300.0 (100 + 200)
"""
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
if stationing_nest is None:
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment) or 0.0
return float(start_station) + dist_along
referents = [
(
_referent_distance_along(referent),
ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station"),
ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="HasIncreasingStation"),
)
for referent in stationing_nest.RelatedObjects
]
referents.sort(key=lambda entry: entry[0])
# Assign each referent's region a direction sign: +1 increasing, -1 decreasing. The sign
# starts increasing and flips at any referent carrying an explicit HasIncreasingStation.
sigma = 1.0
stations = []
for distance_along_ref, outgoing_station, has_increasing_station in referents:
if has_increasing_station is not None:
sigma = 1.0 if has_increasing_station else -1.0
if outgoing_station is not None:
stations.append((distance_along_ref, float(outgoing_station), sigma))
if not stations:
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment) or 0.0
return float(start_station) + dist_along
# Find governing referent: the last one whose DistanceAlong does not exceed dist_along.
# If dist_along precedes the first referent, extrapolate backward from it.
governing = stations[0]
for entry in stations:
if entry[0] <= dist_along + 1e-9:
governing = entry
ref_dist, ref_station, ref_sigma = governing
return ref_station + ref_sigma * (dist_along - ref_dist)
@@ -0,0 +1,577 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
"""Tests for station_from_distance_along — the inverse of distance_along_from_station.
Each test exercises a distinct stationing configuration. The expected values are derived from the
same referent tables used in the distance_along_from_station tests so the two functions are
verifiably inverse (within floating-point tolerance) where the mapping is one-to-one.
"""
import pytest
import ifcopenshell.api.alignment
import ifcopenshell.api.context
import ifcopenshell.api.unit
from ifcopenshell.api.alignment._referent_distance_along import _referent_distance_along
try:
ifcopenshell.file(schema="IFC4X3_ADD2")
IFC4X3_AVAILABLE = True
except RuntimeError:
IFC4X3_AVAILABLE = False
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_file():
file = ifcopenshell.file(schema="IFC4X3_ADD2")
file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot")
ifcopenshell.api.unit.assign_unit(file, units=[length])
ctx = ifcopenshell.api.context.add_context(file, context_type="Model")
ifcopenshell.api.context.add_context(
file,
context_type="Model",
context_identifier="Axis",
target_view="MODEL_VIEW",
parent=ctx,
)
return file
def _short_alignment(file, start_station=None):
"""A compact horizontal-only alignment long enough for all tests (~1 300 ft)."""
coordinates = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0)]
radii = [(1000.0)]
return ifcopenshell.api.alignment.create_by_pi_method(
file, "TestAlignment", coordinates, radii, start_station=start_station
)
def _long_alignment(file, start_station=None):
"""A longer alignment (~8 000 ft), matching the fixture in test_distance_along_from_station."""
coordinates = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)]
radii = [(1000.0), (1250.0), (950.0)]
return ifcopenshell.api.alignment.create_by_pi_method(
file, "TestAlignment", coordinates, radii, start_station=start_station
)
sfda = ifcopenshell.api.alignment.station_from_distance_along
dafs = ifcopenshell.api.alignment.distance_along_from_station
# ---------------------------------------------------------------------------
# Test 1 — no stationing nest (get_stationing_nest returns None)
# ---------------------------------------------------------------------------
def test_no_stationing_nest_returns_dist_along():
# create_by_pi_method without start_station creates no referent; the
# start station defaults to 0.0 and station == dist_along.
file = _make_file()
alignment = _short_alignment(file) # no start_station arg
assert sfda(file, alignment, 0.0) == pytest.approx(0.0)
assert sfda(file, alignment, 300.0) == pytest.approx(300.0)
assert sfda(file, alignment, 750.5) == pytest.approx(750.5)
# ---------------------------------------------------------------------------
# Test 2 — single referent at D 0 (start station, no equations)
# ---------------------------------------------------------------------------
@pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available")
def test_single_start_station_referent():
# Alignment with start station 10 000; one referent at D 0, S 10 000.
# station = 10 000 + dist_along.
file = _make_file()
alignment = _long_alignment(file, start_station=10000.0)
assert sfda(file, alignment, 0.0) == pytest.approx(10000.0)
assert sfda(file, alignment, 3883.96) == pytest.approx(13883.96)
assert sfda(file, alignment, 7525.36) == pytest.approx(17525.36)
@pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available")
def test_single_start_station_referent_roundtrip():
# station_from_distance_along and distance_along_from_station are inverses.
file = _make_file()
alignment = _long_alignment(file, start_station=10000.0)
for d in (0.0, 500.0, 3883.96, 7525.36):
sta = sfda(file, alignment, d)
assert dafs(file, alignment, sta) == pytest.approx(d)
# ---------------------------------------------------------------------------
# Test 3 — gap station equation
# ---------------------------------------------------------------------------
def test_gap_station_equation():
# Reproduces the IFC Alignment Implementation Guide example (§ 9.2.6):
# P0: D 0, S 1 000 (from start_station=1000)
# P3: D 400, S 1 700 (incoming 1 400 → gap of 300 stations)
# P4: D 600, S 1 850 (incoming 1 900 → overlap of 50 stations)
#
# station_from_distance_along is total — every dist_along has a station.
# The gap/overlap affects the *station → dist* direction only.
file = _make_file()
alignment = _long_alignment(file, start_station=1000.0)
ifcopenshell.api.alignment.add_stationing_referent(
file, "P3", alignment, distance_along=400.0, station=1700.0, incoming_station=1400.0
)
ifcopenshell.api.alignment.add_stationing_referent(
file, "P4", alignment, distance_along=600.0, station=1850.0, incoming_station=1900.0
)
# region governed by P0 (D 0400, sigma +1)
assert sfda(file, alignment, 0.0) == pytest.approx(1000.0)
assert sfda(file, alignment, 300.0) == pytest.approx(1300.0)
# at the gap equation referent itself
assert sfda(file, alignment, 400.0) == pytest.approx(1700.0)
# region governed by P3 (D 400600, sigma +1): stations 1 7001 900
# (stations 1 4001 700 are in the gap — skipped over in the D→S direction)
assert sfda(file, alignment, 450.0) == pytest.approx(1750.0)
# at the overlap equation referent: outgoing station 1 850
assert sfda(file, alignment, 600.0) == pytest.approx(1850.0)
# region governed by P4 (D >= 600, sigma +1)
assert sfda(file, alignment, 675.0) == pytest.approx(1925.0)
def test_gap_station_equation_roundtrip():
# For stations outside the gap, distance_along_from_station is the inverse.
file = _make_file()
alignment = _long_alignment(file, start_station=1000.0)
ifcopenshell.api.alignment.add_stationing_referent(
file, "P3", alignment, distance_along=400.0, station=1700.0, incoming_station=1400.0
)
ifcopenshell.api.alignment.add_stationing_referent(
file, "P4", alignment, distance_along=600.0, station=1850.0, incoming_station=1900.0
)
for d in (300.0, 450.0, 675.0):
sta = sfda(file, alignment, d)
assert dafs(file, alignment, sta) == pytest.approx(d)
# ---------------------------------------------------------------------------
# Test 4 — overlap station equation
# ---------------------------------------------------------------------------
def test_overlap_station_equation():
# Two dist_alongs that bracket the overlap equation at P4 both return
# stations within the overlap band — no ambiguity going D→S.
# P4: D 600, incoming 1 900, outgoing 1 850 → overlap [1 850, 1 900]
file = _make_file()
alignment = _long_alignment(file, start_station=1000.0)
ifcopenshell.api.alignment.add_stationing_referent(
file, "P3", alignment, distance_along=400.0, station=1700.0, incoming_station=1400.0
)
ifcopenshell.api.alignment.add_stationing_referent(
file, "P4", alignment, distance_along=600.0, station=1850.0, incoming_station=1900.0
)
# D 550 is in P3's region: station = 1700 + 1*(550-400) = 1850
assert sfda(file, alignment, 550.0) == pytest.approx(1850.0)
# D 600 is P4's outgoing point: station = 1850
assert sfda(file, alignment, 600.0) == pytest.approx(1850.0)
# D 625 is past P4: station = 1850 + 1*(625-600) = 1875
assert sfda(file, alignment, 625.0) == pytest.approx(1875.0)
# ---------------------------------------------------------------------------
# Test 5 — decreasing stationing with a gap equation
# ---------------------------------------------------------------------------
def test_decreasing_stationing_with_gap():
# Mirrors test_distance_along_from_station_reverse_stationing_with_gap_equation:
# R1: D 0, S 2 000, HasIncreasingStation=False → sigma -1
# R2: D 400, S 1 550, incoming 1 600 → sigma -1 (inherited)
# R3: D 800, S 1 150 → sigma -1 (inherited)
file = _make_file()
alignment = _long_alignment(file)
ifcopenshell.api.alignment.add_stationing_referent(
file, "R1", alignment, distance_along=0.0, station=2000.0, has_increasing_station=False
)
ifcopenshell.api.alignment.add_stationing_referent(
file, "R2", alignment, distance_along=400.0, station=1550.0, incoming_station=1600.0
)
ifcopenshell.api.alignment.add_stationing_referent(file, "R3", alignment, distance_along=800.0, station=1150.0)
# R1's region (D 0400, sigma -1): station = 2000 - dist_along
assert sfda(file, alignment, 0.0) == pytest.approx(2000.0)
assert sfda(file, alignment, 200.0) == pytest.approx(1800.0)
# at R2 (D 400): station = 1550
assert sfda(file, alignment, 400.0) == pytest.approx(1550.0)
# R2's region (D 400800, sigma -1): station = 1550 - (dist-400)
assert sfda(file, alignment, 650.0) == pytest.approx(1300.0)
# at R3 (D 800): station = 1150
assert sfda(file, alignment, 800.0) == pytest.approx(1150.0)
# past R3 (sigma -1 continues)
assert sfda(file, alignment, 950.0) == pytest.approx(1000.0)
def test_decreasing_stationing_roundtrip():
# distance_along_from_station is the inverse except within the gap zone.
file = _make_file()
alignment = _long_alignment(file)
ifcopenshell.api.alignment.add_stationing_referent(
file, "R1", alignment, distance_along=0.0, station=2000.0, has_increasing_station=False
)
ifcopenshell.api.alignment.add_stationing_referent(
file, "R2", alignment, distance_along=400.0, station=1550.0, incoming_station=1600.0
)
ifcopenshell.api.alignment.add_stationing_referent(file, "R3", alignment, distance_along=800.0, station=1150.0)
for d in (200.0, 650.0, 950.0):
sta = sfda(file, alignment, d)
assert dafs(file, alignment, sta) == pytest.approx(d)
# ---------------------------------------------------------------------------
# Test 6 — direction switch: increasing then decreasing (peak)
# ---------------------------------------------------------------------------
def test_direction_switch_increasing_then_decreasing():
# Mirrors test_distance_along_from_station_direction_switch_increasing_then_decreasing:
# R1: D 0, S 1 000 sigma +1 [0, 500]
# R2: D 500, S 1 500, HasIncreasingStation=False sigma -1 [500, 1000]
# R3: D 1000, S 1 000 sigma -1
file = _make_file()
alignment = _long_alignment(file)
ifcopenshell.api.alignment.add_stationing_referent(file, "R1", alignment, distance_along=0.0, station=1000.0)
ifcopenshell.api.alignment.add_stationing_referent(
file, "R2", alignment, distance_along=500.0, station=1500.0, has_increasing_station=False
)
ifcopenshell.api.alignment.add_stationing_referent(file, "R3", alignment, distance_along=1000.0, station=1000.0)
# rising region [0, 500]: station = 1000 + dist_along
assert sfda(file, alignment, 0.0) == pytest.approx(1000.0)
assert sfda(file, alignment, 200.0) == pytest.approx(1200.0)
# at the peak (D 500): station = 1500
assert sfda(file, alignment, 500.0) == pytest.approx(1500.0)
# falling region [500, 1000]: station = 1500 - (dist - 500)
assert sfda(file, alignment, 800.0) == pytest.approx(1200.0)
# at the end (D 1000): station = 1000
assert sfda(file, alignment, 1000.0) == pytest.approx(1000.0)
def test_direction_switch_increasing_then_decreasing_symmetric_stations():
# D 200 and D 800 both map to station 1200 — two dist_alongs, one station.
# station_from_distance_along handles this without ambiguity (D→S is always 1:1).
file = _make_file()
alignment = _long_alignment(file)
ifcopenshell.api.alignment.add_stationing_referent(file, "R1", alignment, distance_along=0.0, station=1000.0)
ifcopenshell.api.alignment.add_stationing_referent(
file, "R2", alignment, distance_along=500.0, station=1500.0, has_increasing_station=False
)
ifcopenshell.api.alignment.add_stationing_referent(file, "R3", alignment, distance_along=1000.0, station=1000.0)
assert sfda(file, alignment, 200.0) == pytest.approx(1200.0)
assert sfda(file, alignment, 800.0) == pytest.approx(1200.0)
# ---------------------------------------------------------------------------
# Test 7 — direction switch: decreasing then increasing (valley)
# ---------------------------------------------------------------------------
def test_direction_switch_decreasing_then_increasing():
# Mirrors test_distance_along_from_station_direction_switch_decreasing_then_increasing:
# R1: D 0, S 2 000, HasIncreasingStation=False sigma -1 [0, 500]
# R2: D 500, S 1 500, HasIncreasingStation=True sigma +1 [500, 1000]
# R3: D 1000, S 2 000 sigma +1
file = _make_file()
alignment = _long_alignment(file)
ifcopenshell.api.alignment.add_stationing_referent(
file, "R1", alignment, distance_along=0.0, station=2000.0, has_increasing_station=False
)
ifcopenshell.api.alignment.add_stationing_referent(
file, "R2", alignment, distance_along=500.0, station=1500.0, has_increasing_station=True
)
ifcopenshell.api.alignment.add_stationing_referent(file, "R3", alignment, distance_along=1000.0, station=2000.0)
# falling region [0, 500]: station = 2000 - dist_along
assert sfda(file, alignment, 0.0) == pytest.approx(2000.0)
assert sfda(file, alignment, 200.0) == pytest.approx(1800.0)
# at the valley (D 500): station = 1500
assert sfda(file, alignment, 500.0) == pytest.approx(1500.0)
# rising region [500, 1000]: station = 1500 + (dist - 500)
assert sfda(file, alignment, 800.0) == pytest.approx(1800.0)
# at the end (D 1000): station = 2000
assert sfda(file, alignment, 1000.0) == pytest.approx(2000.0)
# ---------------------------------------------------------------------------
# Test 8 — multiple direction switches
# ---------------------------------------------------------------------------
def test_multiple_direction_switches():
# Mirrors test_distance_along_from_station_multiple_direction_switches.
# Stationing direction flips at every referent (valid but not realistic IFC):
# R1 D 0 S 1000 sigma +1 [0, 300] 1000→1300
# R2 D 300 S 1300 HIS=False sigma -1 [300, 600] 1300→1000
# R3 D 600 S 1000 HIS=True sigma +1 [600, 900] 1000→1300
# R4 D 900 S 1300 HIS=False sigma -1 [900, 1200] 1300→1000
# R5 D 1200 S 1000
file = _make_file()
alignment = _long_alignment(file)
ifcopenshell.api.alignment.add_stationing_referent(file, "R1", alignment, distance_along=0.0, station=1000.0)
ifcopenshell.api.alignment.add_stationing_referent(
file, "R2", alignment, distance_along=300.0, station=1300.0, has_increasing_station=False
)
ifcopenshell.api.alignment.add_stationing_referent(
file, "R3", alignment, distance_along=600.0, station=1000.0, has_increasing_station=True
)
ifcopenshell.api.alignment.add_stationing_referent(
file, "R4", alignment, distance_along=900.0, station=1300.0, has_increasing_station=False
)
ifcopenshell.api.alignment.add_stationing_referent(file, "R5", alignment, distance_along=1200.0, station=1000.0)
# each region boundary
assert sfda(file, alignment, 0.0) == pytest.approx(1000.0)
assert sfda(file, alignment, 300.0) == pytest.approx(1300.0)
assert sfda(file, alignment, 600.0) == pytest.approx(1000.0)
assert sfda(file, alignment, 900.0) == pytest.approx(1300.0)
assert sfda(file, alignment, 1200.0) == pytest.approx(1000.0)
# mid-region values
assert sfda(file, alignment, 150.0) == pytest.approx(1150.0) # [0,300] rising
assert sfda(file, alignment, 450.0) == pytest.approx(1150.0) # [300,600] falling
assert sfda(file, alignment, 750.0) == pytest.approx(1150.0) # [600,900] rising
assert sfda(file, alignment, 1050.0) == pytest.approx(1150.0) # [900,1200] falling
# same station (1100) appears in all four regions
assert sfda(file, alignment, 100.0) == pytest.approx(1100.0)
assert sfda(file, alignment, 500.0) == pytest.approx(1100.0)
assert sfda(file, alignment, 700.0) == pytest.approx(1100.0)
assert sfda(file, alignment, 1100.0) == pytest.approx(1100.0)
# ---------------------------------------------------------------------------
# Test 9 — extrapolation before the first referent
# ---------------------------------------------------------------------------
def test_extrapolation_before_first_referent():
# When dist_along precedes the first referent's DistanceAlong, the station
# is extrapolated backward using the first referent's sigma.
file = _make_file()
alignment = _long_alignment(file)
# Single referent at D 100, S 1100, sigma +1.
# dist_along 0 → station = 1100 + 1*(0-100) = 1000.
ifcopenshell.api.alignment.add_stationing_referent(
file, "R1", alignment, distance_along=100.0, station=1100.0
)
assert sfda(file, alignment, 0.0) == pytest.approx(1000.0)
assert sfda(file, alignment, 50.0) == pytest.approx(1050.0)
assert sfda(file, alignment, 100.0) == pytest.approx(1100.0)
assert sfda(file, alignment, 300.0) == pytest.approx(1300.0)
def test_extrapolation_before_first_referent_decreasing():
# Decreasing stationing: first referent at D 100, S 1100, sigma -1.
# dist_along 0 → station = 1100 + (-1)*(0-100) = 1200.
file = _make_file()
alignment = _long_alignment(file)
ifcopenshell.api.alignment.add_stationing_referent(
file, "R1", alignment, distance_along=100.0, station=1100.0, has_increasing_station=False
)
assert sfda(file, alignment, 0.0) == pytest.approx(1200.0)
assert sfda(file, alignment, 50.0) == pytest.approx(1150.0)
assert sfda(file, alignment, 100.0) == pytest.approx(1100.0)
assert sfda(file, alignment, 300.0) == pytest.approx(900.0)
# ---------------------------------------------------------------------------
# Test 10 — referent nest ordering drives the governing-referent walk
# ---------------------------------------------------------------------------
def test_referents_nest_in_distance_along_order():
# add_stationing_referent is called out of DistanceAlong order; the nest must
# still be sorted by DistanceAlong, and station_from_distance_along must pick
# the last referent whose DistanceAlong does not exceed dist_along.
file = _make_file()
alignment = _long_alignment(file, start_station=1000.0)
ifcopenshell.api.alignment.add_stationing_referent(file, "R3", alignment, distance_along=800.0, station=1800.0)
ifcopenshell.api.alignment.add_stationing_referent(file, "R2", alignment, distance_along=400.0, station=1400.0)
# nest is sorted by DistanceAlong: the D 0 start referent, then R2 (D 400), then R3 (D 800),
# regardless of the order add_stationing_referent was called in.
nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
assert [r.Name for r in nest.RelatedObjects][1:] == ["R2", "R3"]
assert [_referent_distance_along(r) for r in nest.RelatedObjects] == [0.0, 400.0, 800.0]
# governed by the D 0 start referent
assert sfda(file, alignment, 200.0) == pytest.approx(1200.0)
# governed by R2 (D 400): station = 1400 + (600 - 400)
assert sfda(file, alignment, 600.0) == pytest.approx(1600.0)
# governed by R3 (D 800): station = 1800 + (900 - 800)
assert sfda(file, alignment, 900.0) == pytest.approx(1900.0)
# ---------------------------------------------------------------------------
# Test 11 — additional roundtrips through distance_along_from_station
# ---------------------------------------------------------------------------
def test_overlap_station_equation_roundtrip():
# In the region *after* an overlap equation the mapping is one-to-one, so
# distance_along_from_station recovers the original dist_along.
file = _make_file()
alignment = _long_alignment(file, start_station=1000.0)
ifcopenshell.api.alignment.add_stationing_referent(
file, "P3", alignment, distance_along=400.0, station=1700.0, incoming_station=1400.0
)
ifcopenshell.api.alignment.add_stationing_referent(
file, "P4", alignment, distance_along=600.0, station=1850.0, incoming_station=1900.0
)
for d in (625.0, 675.0, 900.0):
sta = sfda(file, alignment, d)
assert dafs(file, alignment, sta) == pytest.approx(d)
def test_direction_switch_decreasing_then_increasing_roundtrip():
# The valley: dist_alongs in the final (rising) region roundtrip cleanly;
# distance_along_from_station returns the most downstream match.
file = _make_file()
alignment = _long_alignment(file)
ifcopenshell.api.alignment.add_stationing_referent(
file, "R1", alignment, distance_along=0.0, station=2000.0, has_increasing_station=False
)
ifcopenshell.api.alignment.add_stationing_referent(
file, "R2", alignment, distance_along=500.0, station=1500.0, has_increasing_station=True
)
ifcopenshell.api.alignment.add_stationing_referent(file, "R3", alignment, distance_along=1000.0, station=2000.0)
for d in (600.0, 800.0, 1000.0):
sta = sfda(file, alignment, d)
assert dafs(file, alignment, sta) == pytest.approx(d)
def test_multiple_direction_switches_roundtrip():
# Every region boundary and the shared mid-region station roundtrip to the
# most downstream distance along.
file = _make_file()
alignment = _long_alignment(file)
ifcopenshell.api.alignment.add_stationing_referent(file, "R1", alignment, distance_along=0.0, station=1000.0)
ifcopenshell.api.alignment.add_stationing_referent(
file, "R2", alignment, distance_along=300.0, station=1300.0, has_increasing_station=False
)
ifcopenshell.api.alignment.add_stationing_referent(
file, "R3", alignment, distance_along=600.0, station=1000.0, has_increasing_station=True
)
ifcopenshell.api.alignment.add_stationing_referent(
file, "R4", alignment, distance_along=900.0, station=1300.0, has_increasing_station=False
)
ifcopenshell.api.alignment.add_stationing_referent(file, "R5", alignment, distance_along=1200.0, station=1000.0)
# station 1100 appears in every region; both functions agree on the last one
assert sfda(file, alignment, 1100.0) == pytest.approx(1100.0)
assert dafs(file, alignment, 1100.0) == pytest.approx(1100.0)
# ---------------------------------------------------------------------------
# Test 12 — negative and fractional distances along (no stationing nest)
# ---------------------------------------------------------------------------
def test_negative_and_fractional_dist_along_no_nest():
# With no stationing nest the result is start_station (0.0) + dist_along for
# any real dist_along, including negative and fractional values.
file = _make_file()
alignment = _long_alignment(file) # no start_station -> no referent, no nest
assert ifcopenshell.api.alignment.get_stationing_nest(file, alignment) is None
assert sfda(file, alignment, -25.0) == pytest.approx(-25.0)
assert sfda(file, alignment, 0.0) == pytest.approx(0.0)
assert sfda(file, alignment, 123.456) == pytest.approx(123.456)
# ---------------------------------------------------------------------------
# Allow running the module directly for quick verification
# ---------------------------------------------------------------------------
if __name__ == "__main__":
test_no_stationing_nest_returns_dist_along()
test_single_start_station_referent()
test_single_start_station_referent_roundtrip()
test_gap_station_equation()
test_gap_station_equation_roundtrip()
test_overlap_station_equation()
test_decreasing_stationing_with_gap()
test_decreasing_stationing_roundtrip()
test_direction_switch_increasing_then_decreasing()
test_direction_switch_increasing_then_decreasing_symmetric_stations()
test_direction_switch_decreasing_then_increasing()
test_multiple_direction_switches()
test_extrapolation_before_first_referent()
test_extrapolation_before_first_referent_decreasing()
test_referents_nest_in_distance_along_order()
test_overlap_station_equation_roundtrip()
test_direction_switch_decreasing_then_increasing_roundtrip()
test_multiple_direction_switches_roundtrip()
test_negative_and_fractional_dist_along_no_nest()
print("All tests passed.")