gnsskit 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gnsskit/__init__.py +76 -0
- gnsskit/kinematic/__init__.py +5 -0
- gnsskit/kinematic/trajectory.py +84 -0
- gnsskit/ppp/__init__.py +16 -0
- gnsskit/py.typed +0 -0
- gnsskit/refsys.py +308 -0
- gnsskit/rinex.py +467 -0
- gnsskit/rtklib/__init__.py +17 -0
- gnsskit/rtklib/config.py +313 -0
- gnsskit/rtklib/pos.py +158 -0
- gnsskit/rtklib/runner.py +146 -0
- gnsskit/static/__init__.py +31 -0
- gnsskit/static/baseline.py +323 -0
- gnsskit/static/closure.py +79 -0
- gnsskit/static/network.py +448 -0
- gnsskit/static/solve.py +54 -0
- gnsskit-0.4.0.dist-info/METADATA +72 -0
- gnsskit-0.4.0.dist-info/RECORD +20 -0
- gnsskit-0.4.0.dist-info/WHEEL +4 -0
- gnsskit-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Static GNSS baselines: data model, extraction from RTKLIB solutions, processing, storage.
|
|
2
|
+
|
|
3
|
+
A baseline is the ECEF vector base -> rover with its 3x3 covariance and the quality
|
|
4
|
+
indicators of the solution. `BaselineResult` is independent of RTKLIB: the RTKLIB-specific
|
|
5
|
+
part is only how it is built (`baseline_from_solution`, `extract_baseline`) and computed
|
|
6
|
+
(`process_baseline`), so other solvers can produce the same objects for `gnsskit.static.network`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Hashable, Mapping, Sequence
|
|
12
|
+
from dataclasses import dataclass, replace
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
|
|
19
|
+
from ..rtklib.config import RtkConfig
|
|
20
|
+
from ..rtklib.pos import POS_COORD_COLS, pos_covariance, read_pos, read_ref_pos
|
|
21
|
+
from ..rtklib.runner import run_rnx2rtkp
|
|
22
|
+
|
|
23
|
+
# =============================================================================
|
|
24
|
+
# Data model
|
|
25
|
+
# =============================================================================
|
|
26
|
+
|
|
27
|
+
# Upper-triangular entries of the 3x3 covariance -> suffix of the table column
|
|
28
|
+
_COV_ENTRIES = {
|
|
29
|
+
"xx": (0, 0),
|
|
30
|
+
"xy": (0, 1),
|
|
31
|
+
"xz": (0, 2),
|
|
32
|
+
"yy": (1, 1),
|
|
33
|
+
"yz": (1, 2),
|
|
34
|
+
"zz": (2, 2),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class BaselineResult:
|
|
40
|
+
"""Final static solution of one baseline: the vector base -> rover (ECEF) + quality."""
|
|
41
|
+
|
|
42
|
+
rover: str
|
|
43
|
+
base: str
|
|
44
|
+
session: str
|
|
45
|
+
epoch: pd.Timestamp # epoch of the solution (GPST)
|
|
46
|
+
dxyz: np.ndarray # (3,) m, X_rover - X_base
|
|
47
|
+
cov: np.ndarray # (3, 3) m^2, formal covariance of dxyz
|
|
48
|
+
q: int # 1 = fixed, 2 = float
|
|
49
|
+
ns: int # satellites used
|
|
50
|
+
ratio: float # ambiguity validation ratio of the final epoch
|
|
51
|
+
pos_file: Path | None = None
|
|
52
|
+
# Statistics over all the epochs of the solution (NaN if unknown); with RTKLIB
|
|
53
|
+
# out-solstatic=single there is only the final epoch, so they say nothing
|
|
54
|
+
n_epochs: int = 0 # solution epochs
|
|
55
|
+
fix_pct: float = float("nan") # % of fixed epochs (Q == 1)
|
|
56
|
+
ratio_median: float = float("nan") # median ratio over the epochs
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def length(self) -> float:
|
|
60
|
+
"""3D baseline length [m]."""
|
|
61
|
+
return float(np.linalg.norm(self.dxyz))
|
|
62
|
+
|
|
63
|
+
def is_good(self, min_ratio: float = 3.0, min_sats: int = 6) -> bool:
|
|
64
|
+
"""Whether the solution is fixed, with enough satellites and a sufficient ratio.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
min_ratio: Minimum ambiguity validation ratio.
|
|
68
|
+
min_sats: Minimum number of satellites used.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
True if Q is 1 (fixed), ratio >= `min_ratio` and ns >= `min_sats`.
|
|
72
|
+
"""
|
|
73
|
+
return self.q == 1 and self.ratio >= min_ratio and self.ns >= min_sats
|
|
74
|
+
|
|
75
|
+
def to_row(self) -> dict[str, Any]:
|
|
76
|
+
"""Flat dict of the baseline: one table row (see `baselines_to_df`)."""
|
|
77
|
+
return {
|
|
78
|
+
"session": self.session,
|
|
79
|
+
"base": self.base,
|
|
80
|
+
"rover": self.rover,
|
|
81
|
+
"epoch": self.epoch,
|
|
82
|
+
"dx": self.dxyz[0],
|
|
83
|
+
"dy": self.dxyz[1],
|
|
84
|
+
"dz": self.dxyz[2],
|
|
85
|
+
**{f"cov_{k}": self.cov[i, j] for k, (i, j) in _COV_ENTRIES.items()},
|
|
86
|
+
"length": self.length,
|
|
87
|
+
"Q": self.q,
|
|
88
|
+
"ns": self.ns,
|
|
89
|
+
"ratio": self.ratio,
|
|
90
|
+
"n_epochs": self.n_epochs,
|
|
91
|
+
"fix_pct": self.fix_pct,
|
|
92
|
+
"ratio_median": self.ratio_median,
|
|
93
|
+
"pos_file": str(self.pos_file) if self.pos_file else "",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def from_row(cls, row: Mapping[Hashable, Any]) -> BaselineResult:
|
|
98
|
+
"""Inverse of `to_row`; `length` is recomputed, not read.
|
|
99
|
+
|
|
100
|
+
Tables without the epoch statistics (older files) give n_epochs 0 and NaN.
|
|
101
|
+
"""
|
|
102
|
+
cov = np.zeros((3, 3))
|
|
103
|
+
for k, (i, j) in _COV_ENTRIES.items():
|
|
104
|
+
cov[i, j] = cov[j, i] = row[f"cov_{k}"]
|
|
105
|
+
session, pos_file = row.get("session"), row.get("pos_file")
|
|
106
|
+
return cls(
|
|
107
|
+
rover=str(row["rover"]),
|
|
108
|
+
base=str(row["base"]),
|
|
109
|
+
session=session if isinstance(session, str) else "",
|
|
110
|
+
epoch=_timestamp(row["epoch"]),
|
|
111
|
+
dxyz=np.array([row["dx"], row["dy"], row["dz"]], dtype=float),
|
|
112
|
+
cov=cov,
|
|
113
|
+
q=int(row["Q"]),
|
|
114
|
+
ns=int(row["ns"]),
|
|
115
|
+
ratio=float(row["ratio"]),
|
|
116
|
+
pos_file=Path(pos_file) if isinstance(pos_file, str) and pos_file else None,
|
|
117
|
+
n_epochs=int(_float(row.get("n_epochs"), 0.0)),
|
|
118
|
+
fix_pct=_float(row.get("fix_pct")),
|
|
119
|
+
ratio_median=_float(row.get("ratio_median")),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _float(value: Any, missing: float = float("nan")) -> float:
|
|
124
|
+
"""Float from a table cell; `missing` for an absent or empty cell."""
|
|
125
|
+
return missing if value is None or value == "" or pd.isna(value) else float(value)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _timestamp(value: Any) -> pd.Timestamp:
|
|
129
|
+
"""Timestamp from a value, rejecting missing epochs (NaT)."""
|
|
130
|
+
t = pd.Timestamp(value)
|
|
131
|
+
if not isinstance(t, pd.Timestamp):
|
|
132
|
+
raise ValueError(f"invalid baseline epoch: {value!r}")
|
|
133
|
+
return t
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# =============================================================================
|
|
137
|
+
# Construction from RTKLIB solutions
|
|
138
|
+
# =============================================================================
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def baseline_from_solution(
|
|
142
|
+
sol: pd.Series | Mapping[str, Any],
|
|
143
|
+
base_xyz: Sequence[float] | np.ndarray,
|
|
144
|
+
rover: str,
|
|
145
|
+
base: str,
|
|
146
|
+
session: str = "",
|
|
147
|
+
pos_file: Path | str | None = None,
|
|
148
|
+
) -> BaselineResult:
|
|
149
|
+
"""Baseline from one epoch of an xyz `read_pos` table and the base position.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
sol: One epoch (row) of an xyz `read_pos` table.
|
|
153
|
+
base_xyz: (3,) ECEF base coordinates [m] used in the processing.
|
|
154
|
+
rover: Rover point name.
|
|
155
|
+
base: Base point name.
|
|
156
|
+
session: Session label stored in the result.
|
|
157
|
+
pos_file: Source .pos file, stored for reference.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
The baseline rover - base. Its covariance is the formal covariance of the rover
|
|
161
|
+
solution, which is also that of the baseline because RTKLIB holds the base fixed;
|
|
162
|
+
it is optimistic (see `gnsskit.static.network.K_S`).
|
|
163
|
+
"""
|
|
164
|
+
xyz = np.array([sol[c] for c in POS_COORD_COLS["xyz"]], dtype=float)
|
|
165
|
+
return BaselineResult(
|
|
166
|
+
rover=rover,
|
|
167
|
+
base=base,
|
|
168
|
+
session=session,
|
|
169
|
+
epoch=_timestamp(sol["t"]),
|
|
170
|
+
dxyz=xyz - np.asarray(base_xyz, dtype=float),
|
|
171
|
+
cov=pos_covariance(sol, "xyz"),
|
|
172
|
+
q=int(sol["Q"]),
|
|
173
|
+
ns=int(sol["ns"]),
|
|
174
|
+
ratio=float(sol["ratio"]),
|
|
175
|
+
pos_file=Path(pos_file) if pos_file is not None else None,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def extract_baseline(
|
|
180
|
+
pos_file: Path | str, rover: str, base: str, session: str = ""
|
|
181
|
+
) -> BaselineResult | None:
|
|
182
|
+
"""Final-epoch baseline from an xyz .pos file, with statistics over all its epochs.
|
|
183
|
+
|
|
184
|
+
The vector is the last solution minus the base position in the `% ref pos` header, so the
|
|
185
|
+
file needs `out-solformat=xyz` and `out-outhead=on`. The ratio of the last epoch alone is
|
|
186
|
+
a noisy indicator: `fix_pct` and `ratio_median` describe the whole session, if the file
|
|
187
|
+
has all the epochs (RTKLIB out-solstatic=all, the default).
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
pos_file: RTKLIB .pos file.
|
|
191
|
+
rover: Rover point name.
|
|
192
|
+
base: Base point name.
|
|
193
|
+
session: Session label stored in the result.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
The baseline, or None if the file has no solution epoch.
|
|
197
|
+
|
|
198
|
+
Raises:
|
|
199
|
+
ValueError: If the file is not in xyz format or has no `% ref pos` header line.
|
|
200
|
+
"""
|
|
201
|
+
pos = read_pos(pos_file)
|
|
202
|
+
if pos.attrs["format"] != "xyz":
|
|
203
|
+
raise ValueError(f"{pos_file}: need out-solformat=xyz to extract a baseline")
|
|
204
|
+
if pos.empty:
|
|
205
|
+
return None
|
|
206
|
+
bl = baseline_from_solution(
|
|
207
|
+
pos.iloc[-1], read_ref_pos(pos_file), rover, base, session, pos_file
|
|
208
|
+
)
|
|
209
|
+
return replace(
|
|
210
|
+
bl,
|
|
211
|
+
n_epochs=len(pos),
|
|
212
|
+
fix_pct=100.0 * float((pos["Q"] == 1).mean()),
|
|
213
|
+
ratio_median=float(pos["ratio"].median()),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# =============================================================================
|
|
218
|
+
# Processing with rnx2rtkp
|
|
219
|
+
# =============================================================================
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def process_baseline(
|
|
223
|
+
rover: str,
|
|
224
|
+
base: str,
|
|
225
|
+
rover_obs: Path | str,
|
|
226
|
+
base_obs: Path | str,
|
|
227
|
+
nav: Sequence[Path | str],
|
|
228
|
+
config: RtkConfig,
|
|
229
|
+
out: Path | str,
|
|
230
|
+
start: pd.Timestamp | None = None,
|
|
231
|
+
end: pd.Timestamp | None = None,
|
|
232
|
+
tint: float | None = None,
|
|
233
|
+
session: str = "",
|
|
234
|
+
exe: str = "rnx2rtkp",
|
|
235
|
+
) -> BaselineResult | None:
|
|
236
|
+
"""Run rnx2rtkp for one baseline, keep the .pos file and extract the result.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
rover: Rover point name.
|
|
240
|
+
base: Base point name.
|
|
241
|
+
rover_obs: Rover RINEX observation file.
|
|
242
|
+
base_obs: Base RINEX observation file.
|
|
243
|
+
nav: Navigation, precise orbit and clock files (see `run_rnx2rtkp`).
|
|
244
|
+
config: RTKLIB options; needs `solformat="xyz"`, `outhead=True` and a base position
|
|
245
|
+
(e.g. `base_postype="rinexhead"`), which is written in the `% ref pos` header.
|
|
246
|
+
out: Output .pos file, kept on disk.
|
|
247
|
+
start: Processing start time, GPST.
|
|
248
|
+
end: Processing end time, GPST.
|
|
249
|
+
tint: Processing interval [s]. None for every epoch.
|
|
250
|
+
session: Session label stored in the result.
|
|
251
|
+
exe: rnx2rtkp executable name or path.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
The baseline, or None if rnx2rtkp produced no solution.
|
|
255
|
+
|
|
256
|
+
Raises:
|
|
257
|
+
FileNotFoundError: If the executable is not found.
|
|
258
|
+
RuntimeError: If rnx2rtkp exits with a non-zero code.
|
|
259
|
+
ValueError: If the .pos file is not in xyz format.
|
|
260
|
+
"""
|
|
261
|
+
run_rnx2rtkp(rover_obs, base_obs, nav, config, out, start=start, end=end, tint=tint, exe=exe)
|
|
262
|
+
return extract_baseline(out, rover, base, session)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# =============================================================================
|
|
266
|
+
# Tables and storage
|
|
267
|
+
# =============================================================================
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def baselines_to_df(results: Sequence[BaselineResult]) -> pd.DataFrame:
|
|
271
|
+
"""Tabulate baselines, one row per baseline.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
results: Baselines to tabulate.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
DataFrame with columns session, base, rover, epoch, dx, dy, dz, the covariance terms
|
|
278
|
+
cov_xx, cov_xy, cov_xz, cov_yy, cov_yz, cov_zz [m^2], length, Q, ns, ratio (final
|
|
279
|
+
epoch), n_epochs, fix_pct, ratio_median (all epochs) and pos_file (empty string if
|
|
280
|
+
unknown).
|
|
281
|
+
"""
|
|
282
|
+
return pd.DataFrame([b.to_row() for b in results])
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def df_to_baselines(df: pd.DataFrame) -> list[BaselineResult]:
|
|
286
|
+
"""Inverse of `baselines_to_df`.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
df: Table with the columns written by `baselines_to_df`.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
One `BaselineResult` per row.
|
|
293
|
+
"""
|
|
294
|
+
return [BaselineResult.from_row(r) for r in df.to_dict("records")]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def write_baselines(results: Sequence[BaselineResult], path: Path | str) -> Path:
|
|
298
|
+
"""Save the baselines as CSV (see `baselines_to_df`).
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
results: Baselines to save.
|
|
302
|
+
path: Output CSV file; parent directories are created.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
The output path.
|
|
306
|
+
"""
|
|
307
|
+
path = Path(path)
|
|
308
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
309
|
+
baselines_to_df(results).to_csv(path, index=False)
|
|
310
|
+
return path
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def read_baselines(path: Path | str) -> list[BaselineResult]:
|
|
314
|
+
"""Load baselines saved with `write_baselines`.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
path: CSV file.
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
The baselines; point and session names stay strings (e.g. '063604').
|
|
321
|
+
"""
|
|
322
|
+
df = pd.read_csv(path, dtype={"session": str, "base": str, "rover": str}, keep_default_na=False)
|
|
323
|
+
return df_to_baselines(df)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Loop closures of a baseline network: a quick consistency check before the adjustment.
|
|
2
|
+
|
|
3
|
+
Three points A, B, C joined by the baselines A->B, B->C and C->A close on themselves:
|
|
4
|
+
the sum of the three vectors must be zero. The misclosure shows blunders (wrong fixes,
|
|
5
|
+
wrong antenna heights, wrong point names) without any stochastic model.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from itertools import combinations, product
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pandas as pd
|
|
16
|
+
|
|
17
|
+
from ..refsys import ENUFrame
|
|
18
|
+
from .baseline import BaselineResult
|
|
19
|
+
|
|
20
|
+
COLUMNS = ["loop", "baselines", "dx", "dy", "dz", "closure", "perimeter", "ppm"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _vector(bl: BaselineResult, start: str, end: str) -> np.ndarray:
|
|
24
|
+
"""Vector start -> end of a baseline, reversed if it was measured the other way round."""
|
|
25
|
+
return bl.dxyz if (bl.base, bl.rover) == (start, end) else -bl.dxyz
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def loop_closures(
|
|
29
|
+
baselines: Sequence[BaselineResult], origin: Sequence[float] | np.ndarray | None = None
|
|
30
|
+
) -> pd.DataFrame:
|
|
31
|
+
"""Misclosure of every triangle that can be formed with the baselines.
|
|
32
|
+
|
|
33
|
+
A triangle is any three points that are pairwise joined by at least one baseline. If a
|
|
34
|
+
pair has several baselines (repeated sessions), every combination is reported. Each side
|
|
35
|
+
is taken in the direction a -> b -> c -> a, reversing the baselines measured the other
|
|
36
|
+
way round, so the misclosure is the sum of the three vectors.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
baselines: Baselines of the network.
|
|
40
|
+
origin: ECEF coordinates [m] of a point of the network (e.g. the fixed one). If
|
|
41
|
+
given, the misclosure is also expressed in the east, north, up frame there.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
DataFrame, largest misclosure first, one row per loop: `loop` (points), `baselines`
|
|
45
|
+
("session:base>rover" of the three sides), the misclosure `dx`, `dy`, `dz` [m], its
|
|
46
|
+
norm `closure` [m], the `perimeter` [m], `ppm` (closure / perimeter) and, with
|
|
47
|
+
`origin`, `dE`, `dN`, `dU` and `dH` (horizontal norm) [m]. Empty if no triangle.
|
|
48
|
+
"""
|
|
49
|
+
sides_of: dict[frozenset[str], list[BaselineResult]] = defaultdict(list)
|
|
50
|
+
for bl in baselines:
|
|
51
|
+
sides_of[frozenset((bl.base, bl.rover))].append(bl)
|
|
52
|
+
points = sorted({p for pair in sides_of for p in pair})
|
|
53
|
+
|
|
54
|
+
frame = ENUFrame.from_ecef(origin) if origin is not None else None
|
|
55
|
+
|
|
56
|
+
rows = []
|
|
57
|
+
for a, b, c in combinations(points, 3):
|
|
58
|
+
sides = [(a, b), (b, c), (c, a)]
|
|
59
|
+
if not all(frozenset(s) in sides_of for s in sides):
|
|
60
|
+
continue
|
|
61
|
+
for combo in product(*(sides_of[frozenset(s)] for s in sides)):
|
|
62
|
+
d = np.sum([_vector(bl, *side) for bl, side in zip(combo, sides)], axis=0)
|
|
63
|
+
perimeter = sum(bl.length for bl in combo)
|
|
64
|
+
row = {
|
|
65
|
+
"loop": f"{a}-{b}-{c}",
|
|
66
|
+
"baselines": " ".join(f"{bl.session}:{bl.base}>{bl.rover}" for bl in combo),
|
|
67
|
+
"dx": d[0], "dy": d[1], "dz": d[2],
|
|
68
|
+
"closure": float(np.linalg.norm(d)),
|
|
69
|
+
"perimeter": perimeter,
|
|
70
|
+
"ppm": 1e6 * float(np.linalg.norm(d)) / perimeter,
|
|
71
|
+
} # fmt: skip
|
|
72
|
+
if frame is not None:
|
|
73
|
+
e, n, u = frame.vector_to_enu(d)
|
|
74
|
+
row |= {"dE": e, "dN": n, "dU": u, "dH": float(np.hypot(e, n))}
|
|
75
|
+
rows.append(row)
|
|
76
|
+
cols = COLUMNS + (["dE", "dN", "dU", "dH"] if frame is not None else [])
|
|
77
|
+
return pd.DataFrame(rows, columns=cols).sort_values(
|
|
78
|
+
"closure", ascending=False, ignore_index=True
|
|
79
|
+
)
|