The pytransdatro package is the foundational geodetic computation engine of PyTransDatRO. It executes bidirectional 2D and 3D coordinate transformations between Stereo70 (Dealul Piscului 1970 / Pulkovo 1942(58) Oblique Stereographic, EPSG:31700) and ETRS89 (European Terrestrial Reference System 1989 / GRS80, EPSG:4258), including vertical height transformations between Marea Neagră 1975 (MN75) normal heights (
-
Pure Python Standard Library Processing: All mathematical projections, Helmert transformations, bicubic/bilinear grid interpolations, and coordinate validations are implemented strictly in pure Python (
math,typing,logging,pathlib). -
Strict NumPy Isolation:
numpyis used exclusively insideSpgReaderto unpickle the binary.spggrid package and extract flat Pythontolist()arrays at startup. -
Zero Web Layer Coupling:
pytransdatro/never imports fromapi/,fastapi,pydantic, or any web framework, allowing the package to be used as a standalone scientific library or CLI tool. -
Byte-for-Byte RomGEO Fidelity: Numerical outputs match the official ANCPI / CNC RomGEO desktop reference implementation within machine epsilon (
$< 10^{-9}$ tolerance across all 9,046 national control points).
| Module | Primary Classes / Functions | Responsibility |
|---|---|---|
pytransdatro/trans_ro.py |
TransRO |
Main coordinator orchestrating single-point, vectorized sequence, and 4-stage fault-isolated batch transformations (st70_to_etrs89, etrs89_to_st70, transform_batch), dual-CRS 6-point extended bounding box pre-checks, and telemetry hooks. |
pytransdatro/proj_stereo.py |
StereoProj, _Ellipsoid, _ConfSph
|
Forward (to_grid) and inverse (to_geo) Oblique Stereographic projection between geographic coordinates |
pytransdatro/trans_helmert2d.py |
Helmert2D |
4-parameter 2D Helmert conformal transformation (.spg metadata. |
pytransdatro/trans_grid.py |
Grid, BiInterp, Grid2D, Grid1D
|
Grid boundary verification (covered_by_grid), 16-coefficient bicubic spline (_trans_bicubic), bilinear (_trans_bilinear), and nearest-neighbor (_trans_colocate) grid shift interpolation for 2D planimetric distortions (geodetic_shifts) and 1D quasigeoid undulations (geoid_heights). |
pytransdatro/spg_reader.py |
SpgReader, GridParamData
|
Auto-discovers, validates, and deserializes the active .spg binary grid file in pytransdatro/grids/, computing true array extents directly from matrix dimensions. |
pytransdatro/utils.py |
sexa_to_rad, sexa_dms_chars_to_rad, format_rad_to_dms, rad_to_sexa
|
Angle unit conversions, strict sexagesimal DMS parsing with cardinal indicator validation (N, E, S, W, V), and precision-safe DMS formatting with 60-second rollover. |
pytransdatro/constants.py |
Geodetic & Grid Constants | Immutable ellipsoid parameters, Stereo70 projection origins, conformal sphere constants, and grid No-Data sentinel thresholds (999.0). |
pytransdatro/exceptions.py |
PyTransDatError hierarchy |
Structured domain exceptions for invalid coordinates, wrong dimensionality, 2D/1D out-of-grid bounds, and No-Data grid cells. |
classDiagram
class TransRO {
+grid2d_bounds_st70: Tuple
+grid2d_bounds_etrs89: Tuple
+grid1d_bounds_etrs89: Tuple
+grid1d_bounds_st70: Tuple
+st70_to_etrs89(n, e, z, source)
+etrs89_to_st70(lat, lon, h, source)
+transform_batch(points, from_st70, is_degrees, source)
-_init_dual_crs_extents()
-_precheck_st70_point(n_val, e_val, is_3d)
-_precheck_etrs89_point(lat_rad, lon_rad, is_3d)
-_validate_and_normalize_inputs(c1, c2, c3)
}
class StereoProj {
+to_grid(lat, lon) Tuple~list, list~
+to_geo(n, e) Tuple~list, list~
}
class Helmert2D {
+trans(n, e, direction) Tuple~list, list~
}
class Grid {
<<abstract>>
+covered_by_grid(n, e)
+interp(d_n, d_e, ff) float
}
class Grid2D {
+trans(n, e, direction) Tuple~list, list~
}
class Grid1D {
+trans(n, e, z, direction) Tuple~list~
}
class SpgReader {
+get_grid_2d_params() GridParamData
+get_grid_1d_params() GridParamData
+get_helmert_params(key) Tuple
}
TransRO *-- StereoProj
TransRO *-- Helmert2D
TransRO *-- Grid2D
TransRO *-- Grid1D
Grid <|-- Grid2D
Grid <|-- Grid1D
Grid2D ..> SpgReader : loads geodetic_shifts
Grid1D ..> SpgReader : loads geoid_heights
Helmert2D ..> SpgReader : loads st70_os / os_st70
Notice the asymmetric placement of the 2D planimetric grid shift (Grid2D): because the geodetic_shifts grid is indexed in Stereo70 meters, it is applied first during Stereo70 -> ETRS89 and last (after projecting and inverse-Helmert shifting) during ETRS89 -> Stereo70.Conversely, the 1D quasigeoid grid (Grid1D) is indexed in ETRS89 decimal degrees, so it always evaluates at the ETRS89 geographic coordinates.
Inputs: Northing
-
Input & 6-Point Extended Bounding Box Pre-Check:
- Validates finite numeric inputs and checks
$(N, E)$ againstgrid2d_bounds_st70(andgrid1d_bounds_st70when$H$ is provided).
- Validates finite numeric inputs and checks
-
2D Planimetric Distortion Grid Shift (Inverse,
direction = -1):- Evaluates bicubic spline interpolation on
geodetic_shiftsat$(N, E)$ to obtain local shifts$(\Delta N_{\text{grid}}, \Delta E_{\text{grid}})$ and subtracts them:$$N' = N - \Delta N_{\text{grid}}(N, E), \quad E' = E - \Delta E_{\text{grid}}(N, E)$$
- Evaluates bicubic spline interpolation on
-
2D Helmert Transformation (Forward,
direction = +1):- Transforms Dealul Piscului 1970 planar coordinates
$(N', E')$ to StereoGRS80 planar coordinates$(N_{\text{GRS80}}, E_{\text{GRS80}})$ using parameters$(t_N, t_E, ds, R_z)$ from.spgmetadata (st70_os):$$m = 1 + ds \cdot 10^{-6}$$ $$\begin{bmatrix} E_{\text{GRS80}} \ N_{\text{GRS80}} \end{bmatrix} = \begin{bmatrix} t_E \ t_N \end{bmatrix} + m \begin{bmatrix} \cos R_z & \sin R_z \ -\sin R_z & \cos R_z \end{bmatrix} \begin{bmatrix} E' \ N' \end{bmatrix}$$
- Transforms Dealul Piscului 1970 planar coordinates
-
Inverse Oblique Stereographic Projection (
StereoProj.to_geo):- Projects
$(N_{\text{GRS80}}, E_{\text{GRS80}})$ from the flat projection plane onto the Gaussian conformal sphere of radius$R = \sqrt{M_0 N_0}$ centered at$(\varphi_0 = 46^\circ\text{ N}, \lambda_0 = 25^\circ\text{ E})$ with scale factor$k_0 = 0.99975$ and false origin$N_0 = E_0 = 500,000\text{ m}$ , then iteratively solves for geodetic latitude$\varphi$ and longitude$\lambda$ (in radians) on the GRS80/WGS84 ellipsoid.
- Projects
-
1D Quasigeoid Undulation Correction (Optional 3D,
direction = +1):- When
$H_{\text{MN75}}$ is supplied, converts$(\varphi, \lambda)$ to decimal degrees, interpolates the quasigeoid undulation$\zeta(\varphi^\circ, \lambda^\circ)$ fromgeoid_heights, and adds it to obtain ellipsoidal height$h$ :$$h_{\text{ETRS89}} = H_{\text{MN75}} + \zeta(\varphi^\circ, \lambda^\circ)$$
- When
Outputs:
Inputs: Latitude
-
Input & 6-Point Extended Bounding Box Pre-Check:
- Validates finite numeric inputs and checks
$(\varphi, \lambda)$ againstgrid2d_bounds_etrs89(andgrid1d_bounds_etrs89when$h$ is provided). This immediately blocks out-of-envelope coordinates, pole singularities ($\varphi = \pm 90^\circ$ ), and$2\pi$ periodic radian aliasing before trigonometric projection.
- Validates finite numeric inputs and checks
-
Forward Oblique Stereographic Projection (
StereoProj.to_grid):- Converts geodetic
$(\varphi, \lambda)$ to conformal sphere coordinates$(\chi, \lambda_c)$ and projects them to StereoGRS80 planar coordinates$(N_{\text{GRS80}}, E_{\text{GRS80}})$ :$$b = 1 + \sin\chi \sin\chi_0 + \cos\chi \cos\chi_0 \cos(\lambda_c - \lambda_0)$$ $$N_{\text{GRS80}} = N_0 + \frac{2 R k_0}{b}\big(\sin\chi \cos\chi_0 - \cos\chi \sin\chi_0 \cos(\lambda_c - \lambda_0)\big)$$ $$E_{\text{GRS80}} = E_0 + \frac{2 R k_0}{b}\big(\cos\chi \sin(\lambda_c - \lambda_0)\big)$$
- Converts geodetic
-
2D Helmert Transformation (Inverse,
direction = -1):- Applies the inverse Helmert transformation (
os_st70) to obtain intermediate Stereo70 coordinates$(N', E')$ .
- Applies the inverse Helmert transformation (
-
2D Planimetric Distortion Grid Shift (Forward,
direction = +1):- Checks exact Stereo70 grid coverage (
Grid2D.covered_by_grid), evaluates bicubic spline interpolation ongeodetic_shiftsat$(N', E')$ , and adds the shifts:$$N = N' + \Delta N_{\text{grid}}(N', E'), \quad E = E' + \Delta E_{\text{grid}}(N', E')$$
- Checks exact Stereo70 grid coverage (
-
1D Quasigeoid Undulation Correction (Optional 3D,
direction = -1):- When
$h_{\text{ETRS89}}$ is supplied, evaluatesgeoid_heightsat the input$(\varphi^\circ, \lambda^\circ)$ and subtracts the quasigeoid undulation:$$H_{\text{MN75}} = h_{\text{ETRS89}} - \zeta(\varphi^\circ, \lambda^\circ)$$
- When
Outputs:
Both Grid2D and Grid1D dynamically bind their interpolation method from the .spg metadata header (interpolation_method_id):
-
Bicubic Spline (
method_id = 2, default forGrid2DandGrid1Dinrom_grid3d_25.09.spg):- Extracts a
$4 \times 4$ neighborhood of 16 surrounding grid nodes around the normalized cell offset$(\delta_N, \delta_E) \in [0, 1]^2$ . - If any of the 16 nodes is
NaNor equals the No-Data sentinel (999.0), raisesNoDataGridErr. - Multiplies the 16-element node vector by the precomputed
$16 \times 16$ Catmull-Rom / bicubic spline basis matrix (BiInterp._C) to evaluate the 2D cubic polynomial:$$f(\delta_N, \delta_E) = \sum_{i=0}^{3} \sum_{j=0}^{3} a_{ij} , \delta_N^i , \delta_E^j$$
- Extracts a
-
Bilinear (
method_id = 1): Evaluates the 4-node cell weighted average over$(1 - \delta_N)(1 - \delta_E)$ . -
Nearest-Neighbor Colocation (
method_id = 0): Rounds to the nearest grid node index$(i_r, j_c)$ .
In TransRO, the 2D planimetric grid (geodetic_shifts) is a rectangle in Stereo70 meters (geoid_heights) is a rectangle in ETRS89 degrees (
Because the Oblique Stereographic projection is centered at
-
North and South Borders Curve Vertically: Lines of constant latitude are concentric circles around the North Pole. On the Stereo70 plane, a parallel of constant latitude curves upward at the East/West corners and dips southward at the central meridian (
$\lambda_0 = 25^\circ\text{ E}$ ). Conversely, a straight horizontal line of constant Stereo70 Northing bows northward at the central meridian:- Along the northern border of
Grid2D($N = 783,000\text{ m}$ ), the NW and NE corners have latitude$\varphi \approx 48.4620^\circ\text{ N}$ , but the North midpoint at$E_0 = 500,000\text{ m}$ reaches$\varphi \approx 48.5458^\circ\text{ N}$ ($\sim 9.3\text{ km}$ further North!). - Along the southern border of
Grid1D($\varphi = 43.50^\circ\text{ N}$ ), the SW and SE corners have Stereo70 Northing$N \approx 228,154\text{ m}$ and$226,419\text{ m}$ , but the South midpoint at$\lambda_0 = 25^\circ\text{ E}$ dips down to$N \approx 222,226\text{ m}$ ($\sim 4.2\text{ km}$ further South!).
- Along the northern border of
-
East and West Borders Tilt Monotonically: Meridians converge continuously from South to North without bending back across
$46^\circ\text{ N}$ . Therefore, the 4 corners already capture 100% of the East/West extremes (Northern corners for maximum longitude span ofGrid2D; Southern corners for maximum Easting span ofGrid1D).
At initialization, TransRO._init_dual_crs_extents() projects 6 boundary points for each grid into the opposite CRS:
- Southwest Corner
- Northwest Corner
- Southeast Corner
- Northeast Corner
-
South Midpoint (at
$E_0 = 500,000\text{ m}$ forGrid2D, or$\lambda_0 = 25^\circ\text{ E}$ forGrid1D) -
North Midpoint (at
$E_0 = 500,000\text{ m}$ forGrid2D, or$\lambda_0 = 25^\circ\text{ E}$ forGrid1D)
Taking the minimum and maximum across these 6 points produces a mathematically guaranteed outer bounding box in both CRSs (grid2d_bounds_st70, grid2d_bounds_etrs89, grid1d_bounds_etrs89, grid1d_bounds_st70):
| Spatial Region of Input Point | Stage Caught | Exception Raised |
|---|---|---|
| 1. Outside the 6-Point Extended Envelope | Caught immediately at entry (_precheck_st70_point / _precheck_etrs89_point) before any projection or Helmert math. |
OutOfGrid2DErr (or OutOfGrid1DErr if 3D point is inside 2D box but outside 1D geoid box) |
| 2. Inside Extended Envelope, but inside the "Curved Border Wedge" | Passes pre-check, projects into native grid CRS, and is caught at entry to Grid2D.covered_by_grid or Grid1D.covered_by_grid. |
OutOfGrid2DErr or OutOfGrid1DErr
|
| 3. Inside Native Grid Rectangle, but outside Romania's Border Buffer | Passes covered_by_grid, caught when surrounding 999.0 / NaN. |
NoDataGridErr |
When processing an array of coordinates (e.g., from POST /api/v1/transform/batch or Legacy POST /transdatonline/cooOpService), TransRO.transform_batch() ensures that a single malformed, out-of-grid, or No-Data coordinate never aborts the batch, while still executing vectorized projection/Helmert math and recording a single aggregated telemetry event:
flowchart TD
A["Input Batch: points[0..N-1]"] --> B["Stage 1: Per-Point Syntax, Dimensionality (2D/3D) & 6-Point Envelope Pre-Check"]
B -->|Invalid / Wrong Dim / Out of Envelope| C["Record Error at results[idx]"]
B -->|Valid Survivor| D["Stage 2: Batch Stereographic Projection & 2D Helmert on Survivors"]
D --> E["Stage 3: Fault-Isolated Per-Point Grid2D & Grid1D Interpolation"]
E -->|Curved-Wedge OutOfGrid or NoData| C
E -->|Valid Interpolation| F["Store Transformed Coords at results[idx] & Accumulate Telemetry Lists"]
C --> G["Stage 4: Single-Call Telemetry Recording & Ordered Result Merge"]
F --> G
-
Stage 1 (Pre-Filtering): Iterates over
points, isolating non-list items, non-numeric/NaN/Inf/boolvalues (InvalidCoordinateErr), 1D or $\ge 4$D rows (WrongDimensionErr), and points outside the 6-point extended bounding box (OutOfGrid2DErr/OutOfGrid1DErr). Valid survivors are packed into contiguous coordinate arrays alongside their original indicessurv_idxs. - Stage 2 (Vectorized Projection & Helmert): Projects and Helmert-shifts all surviving coordinates together in a single vectorized pass.
-
Stage 3 (Fault-Isolated Grid Interpolation): Evaluates
Grid2Dand (for 3D points)Grid1Dper survivor inside a targetedtry / except (OutOfGridErr, NoDataGridErr)block so points falling inside a curved border wedge or a No-Data border cell are isolated to their own indexresults[orig_idx]. -
Stage 4 (Single-Call Telemetry & Merge): Calls
self._collector.record(...)once for all valid 2D points and once for all valid 3D points in the batch (1 call, K valid points), returninglist[Tuple[list[float], Optional[PyTransDatError]]]in exact input order.
| Exception Class | Base Class(es) | Trigger Condition |
|---|---|---|
PyTransDatError |
Exception |
Base class for all domain exceptions raised by pytransdatro. |
InvalidCoordinateErr |
ValueError, PyTransDatError
|
Non-numeric coordinate, NaN, Infinity, boolean, pole singularity ( |
WrongDimensionErr |
ValueError, PyTransDatError
|
Empty coordinate sequence, mismatched sequence lengths across (n, e, z), mixed scalar/sequence arguments, or point tuple with |
OutOfGridErr |
ValueError, PyTransDatError
|
Base class for coordinates outside the bounding extent of a transformation grid. Stores .n, .e, and .grid attributes. |
OutOfGrid2DErr |
OutOfGridErr |
Coordinate lies outside the 2D planimetric distortion grid (geodetic_shifts). |
OutOfGrid1DErr |
OutOfGridErr |
3D coordinate lies outside the 1D quasigeoid height grid (geoid_heights). |
NoDataGridErr |
ValueError, PyTransDatError
|
Coordinate is inside the grid rectangle, but one or more surrounding interpolation nodes contain the 999.0 / NaN No-Data sentinel (outside Romania's border buffer). |
GridError |
RuntimeError, PyTransDatError
|
Base class for .spg grid file discovery or loading failures. |
MissingGridError |
FileNotFoundError, GridError
|
No .spg grid file found in pytransdatro/grids/. |
AmbiguousGridError |
GridError |
Multiple .spg grid files found in pytransdatro/grids/ without an explicit path override. |
import math
from pytransdatro import TransRO
engine = TransRO()
# 1. Single 3D point: Stereo70 (N, E, H_MN75) -> ETRS89 (lat_rad, lon_rad, h)
lat_rad, lon_rad, h = engine.st70_to_etrs89(500000.0, 500000.0, 100.0)
print(math.degrees(lat_rad), math.degrees(lon_rad), round(h, 4))
# -> 45.9997186280 24.9984476351 139.6084
# 2. Vectorized sequence of 2D points: ETRS89 (radians) -> Stereo70 (meters)
n_list, e_list = engine.etrs89_to_st70(
[math.radians(46.0), math.radians(45.5)],
[math.radians(25.0), math.radians(24.5)],
)batch_points = [
[500000.0, 500000.0, 100.0], # Valid 3D point
[5.0, 1.0, 100.0], # Outside grid -> OutOfGrid2DErr
[549364.0, 147553.0, 100.0], # Outside border -> NoDataGridErr
[510000.0, 510000.0], # Valid 2D point
]
results = engine.transform_batch(batch_points, from_st70=True)
for coords, err in results:
if err is None:
print("Transformed:", coords)
else:
print("Failed point:", coords, "Reason:", type(err).__name__, "-", err)from pytransdatro import utils
# Parse DMS string with cardinal direction to radians
lat_rad = utils.sexa_dms_chars_to_rad("46°00'00.00000\"N")
# Format radians back to canonical DMS string
dms_str = utils.format_rad_to_dms(lat_rad, precision=5)
print(dms_str) # -> 46°00'00.00000"