unitsleuth 0.1.0__tar.gz
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.
- unitsleuth-0.1.0/.gitignore +10 -0
- unitsleuth-0.1.0/CHANGELOG.md +9 -0
- unitsleuth-0.1.0/LICENSE +21 -0
- unitsleuth-0.1.0/PKG-INFO +88 -0
- unitsleuth-0.1.0/README.md +68 -0
- unitsleuth-0.1.0/pyproject.toml +61 -0
- unitsleuth-0.1.0/src/unitsleuth/__init__.py +19 -0
- unitsleuth-0.1.0/src/unitsleuth/__main__.py +3 -0
- unitsleuth-0.1.0/src/unitsleuth/_frame.py +50 -0
- unitsleuth-0.1.0/src/unitsleuth/catalog.py +117 -0
- unitsleuth-0.1.0/src/unitsleuth/cli.py +104 -0
- unitsleuth-0.1.0/src/unitsleuth/detect.py +236 -0
- unitsleuth-0.1.0/src/unitsleuth/multivariate.py +247 -0
- unitsleuth-0.1.0/src/unitsleuth/py.typed +0 -0
- unitsleuth-0.1.0/tests/test_catalog.py +44 -0
- unitsleuth-0.1.0/tests/test_cli.py +59 -0
- unitsleuth-0.1.0/tests/test_detect.py +78 -0
- unitsleuth-0.1.0/tests/test_frames.py +66 -0
- unitsleuth-0.1.0/tests/test_multivariate.py +82 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
- Catalog of affine unit conversions (length, mass, temperature, volume, pressure, speed, time, ratios, power-of-ten errors).
|
|
6
|
+
- Univariate mixture detector with per-row posteriors and BIC-based evidence.
|
|
7
|
+
- Row-consistent multivariate detection and repair.
|
|
8
|
+
- pandas and Polars support through Narwhals.
|
|
9
|
+
- Command-line interface.
|
unitsleuth-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Md Omar Faruqe
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: unitsleuth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Detect mixed measurement units in unlabeled numeric columns from the numbers alone.
|
|
5
|
+
Project-URL: Homepage, https://github.com/omarfaruqe/unitsleuth
|
|
6
|
+
Project-URL: Issues, https://github.com/omarfaruqe/unitsleuth/issues
|
|
7
|
+
Author-email: Md Omar Faruqe <faruqe@ru.ac.bd>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: data-cleaning,data-quality,measurement,mixture-model,units
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: narwhals>=1.20
|
|
18
|
+
Requires-Dist: numpy>=1.24
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# unitsleuth
|
|
22
|
+
|
|
23
|
+
Detect mixed measurement units in an unlabeled numeric column, from the numbers alone.
|
|
24
|
+
|
|
25
|
+
A height column where most rows are centimetres and a few are inches, or a
|
|
26
|
+
temperature column that mixes °C and °F, looks fine to a schema check. unitsleuth
|
|
27
|
+
fits a mixture model per known unit conversion and tells you which rows were
|
|
28
|
+
probably recorded in the other unit.
|
|
29
|
+
|
|
30
|
+
This is a Python implementation of the mixture-model approach of Di Zio, Guarnera
|
|
31
|
+
and Luzi (2005, *Survey Methodology*), extended with affine conversions
|
|
32
|
+
(e.g. °C to °F) and a catalog of common unit pairs. It is not a new method.
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import numpy as np
|
|
38
|
+
import unitsleuth as us
|
|
39
|
+
|
|
40
|
+
heights = np.concatenate([np.random.normal(170, 9, 900), np.random.normal(67, 3.5, 100)])
|
|
41
|
+
best = us.detect(heights, quantity="length")[0]
|
|
42
|
+
print(best.label, best.weight, best.detected)
|
|
43
|
+
rows = best.converted # boolean mask of rows likely in the other unit
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Whole rows in the wrong unit
|
|
47
|
+
|
|
48
|
+
When a record was entered in one unit system, every column in that row is
|
|
49
|
+
affected. `detect_rows` shares one flag per row across columns, and `repair`
|
|
50
|
+
harmonises the data. `df` can be a pandas or Polars DataFrame (via
|
|
51
|
+
[Narwhals](https://narwhals-dev.github.io/narwhals/)) or a dict of arrays:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
cat = us.catalog
|
|
55
|
+
fit = us.detect_rows(df, {"height": cat.get("cm", "in"), "weight": cat.get("kg", "lb")})
|
|
56
|
+
print(fit.summary())
|
|
57
|
+
fixed = us.repair(df, fit) # same dataframe type, columns corrected
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Command line
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
unitsleuth data.csv --col height:cm,in --col weight:kg,lb --out data_fixed.csv
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Prints the report and, with `--out`, writes a harmonised CSV. The exit code is 1
|
|
67
|
+
when mixed units are detected, 0 when not and 2 on usage errors, so it can gate a
|
|
68
|
+
data pipeline.
|
|
69
|
+
|
|
70
|
+
## Status
|
|
71
|
+
|
|
72
|
+
Alpha. Univariate and row-consistent multivariate detection and repair work;
|
|
73
|
+
dataframe integration (pandas, Polars) and a CLI are planned. Detection needs the units to be
|
|
74
|
+
separated relative to the spread of the data, so it cannot distinguish them when
|
|
75
|
+
natural variation is wide compared with the conversion factor.
|
|
76
|
+
|
|
77
|
+
## Development
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
uv sync
|
|
81
|
+
uv run pytest
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Validation
|
|
85
|
+
|
|
86
|
+
- **Negative control (real data):** on the Palmer penguins dataset (342 birds, 4 numeric columns, 24 catalog conversions each), nothing was flagged: 0 of 96 tests, despite three species with different body sizes.
|
|
87
|
+
- **Positive control (semi-real):** converting a known 5%, 15% or 30% of the penguins' body mass (kg to lb) and bill length (cm to in) and running `detect_rows` over 3 random seeds each, the injected rows were found with 100% row accuracy in all 9 runs, and the estimated share was close to the injected share.
|
|
88
|
+
- **Limits:** these results are for well-separated unit pairs. When the natural spread of a column is wide relative to the conversion factor, the units cannot be told apart from the numbers alone, and the evidence score will say so.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# unitsleuth
|
|
2
|
+
|
|
3
|
+
Detect mixed measurement units in an unlabeled numeric column, from the numbers alone.
|
|
4
|
+
|
|
5
|
+
A height column where most rows are centimetres and a few are inches, or a
|
|
6
|
+
temperature column that mixes °C and °F, looks fine to a schema check. unitsleuth
|
|
7
|
+
fits a mixture model per known unit conversion and tells you which rows were
|
|
8
|
+
probably recorded in the other unit.
|
|
9
|
+
|
|
10
|
+
This is a Python implementation of the mixture-model approach of Di Zio, Guarnera
|
|
11
|
+
and Luzi (2005, *Survey Methodology*), extended with affine conversions
|
|
12
|
+
(e.g. °C to °F) and a catalog of common unit pairs. It is not a new method.
|
|
13
|
+
|
|
14
|
+
## Quick start
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import numpy as np
|
|
18
|
+
import unitsleuth as us
|
|
19
|
+
|
|
20
|
+
heights = np.concatenate([np.random.normal(170, 9, 900), np.random.normal(67, 3.5, 100)])
|
|
21
|
+
best = us.detect(heights, quantity="length")[0]
|
|
22
|
+
print(best.label, best.weight, best.detected)
|
|
23
|
+
rows = best.converted # boolean mask of rows likely in the other unit
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Whole rows in the wrong unit
|
|
27
|
+
|
|
28
|
+
When a record was entered in one unit system, every column in that row is
|
|
29
|
+
affected. `detect_rows` shares one flag per row across columns, and `repair`
|
|
30
|
+
harmonises the data. `df` can be a pandas or Polars DataFrame (via
|
|
31
|
+
[Narwhals](https://narwhals-dev.github.io/narwhals/)) or a dict of arrays:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
cat = us.catalog
|
|
35
|
+
fit = us.detect_rows(df, {"height": cat.get("cm", "in"), "weight": cat.get("kg", "lb")})
|
|
36
|
+
print(fit.summary())
|
|
37
|
+
fixed = us.repair(df, fit) # same dataframe type, columns corrected
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Command line
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
unitsleuth data.csv --col height:cm,in --col weight:kg,lb --out data_fixed.csv
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Prints the report and, with `--out`, writes a harmonised CSV. The exit code is 1
|
|
47
|
+
when mixed units are detected, 0 when not and 2 on usage errors, so it can gate a
|
|
48
|
+
data pipeline.
|
|
49
|
+
|
|
50
|
+
## Status
|
|
51
|
+
|
|
52
|
+
Alpha. Univariate and row-consistent multivariate detection and repair work;
|
|
53
|
+
dataframe integration (pandas, Polars) and a CLI are planned. Detection needs the units to be
|
|
54
|
+
separated relative to the spread of the data, so it cannot distinguish them when
|
|
55
|
+
natural variation is wide compared with the conversion factor.
|
|
56
|
+
|
|
57
|
+
## Development
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
uv sync
|
|
61
|
+
uv run pytest
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Validation
|
|
65
|
+
|
|
66
|
+
- **Negative control (real data):** on the Palmer penguins dataset (342 birds, 4 numeric columns, 24 catalog conversions each), nothing was flagged: 0 of 96 tests, despite three species with different body sizes.
|
|
67
|
+
- **Positive control (semi-real):** converting a known 5%, 15% or 30% of the penguins' body mass (kg to lb) and bill length (cm to in) and running `detect_rows` over 3 random seeds each, the injected rows were found with 100% row accuracy in all 9 runs, and the estimated share was close to the injected share.
|
|
68
|
+
- **Limits:** these results are for well-separated unit pairs. When the natural spread of a column is wide relative to the conversion factor, the units cannot be told apart from the numbers alone, and the evidence score will say so.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "unitsleuth"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Detect mixed measurement units in unlabeled numeric columns from the numbers alone."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [{ name = "Md Omar Faruqe", email = "faruqe@ru.ac.bd" }]
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
keywords = ["data-quality", "units", "measurement", "mixture-model", "data-cleaning"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Intended Audience :: Science/Research",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Topic :: Scientific/Engineering :: Information Analysis",
|
|
15
|
+
"Typing :: Typed",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"narwhals>=1.20",
|
|
19
|
+
"numpy>=1.24",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
unitsleuth = "unitsleuth.cli:main"
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/omarfaruqe/unitsleuth"
|
|
27
|
+
Issues = "https://github.com/omarfaruqe/unitsleuth/issues"
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["hatchling"]
|
|
31
|
+
build-backend = "hatchling.build"
|
|
32
|
+
|
|
33
|
+
[dependency-groups]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=8",
|
|
36
|
+
"pytest-cov>=5",
|
|
37
|
+
"hypothesis>=6",
|
|
38
|
+
"ruff>=0.6",
|
|
39
|
+
"mypy>=1.11",
|
|
40
|
+
"pandas>=2.3.3",
|
|
41
|
+
"polars>=2.0.0",
|
|
42
|
+
"pyarrow>=25.0.1",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[tool.ruff]
|
|
46
|
+
line-length = 88
|
|
47
|
+
target-version = "py310"
|
|
48
|
+
|
|
49
|
+
[tool.ruff.lint]
|
|
50
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "NPY"]
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
testpaths = ["tests"]
|
|
54
|
+
addopts = "-ra --strict-markers"
|
|
55
|
+
|
|
56
|
+
[tool.mypy]
|
|
57
|
+
strict = true
|
|
58
|
+
python_version = "3.10"
|
|
59
|
+
|
|
60
|
+
[tool.hatch.build.targets.sdist]
|
|
61
|
+
include = ["src", "tests", "README.md", "LICENSE", "CHANGELOG.md", "pyproject.toml"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""unitsleuth: detect mixed measurement units in numeric columns."""
|
|
2
|
+
|
|
3
|
+
from unitsleuth import catalog
|
|
4
|
+
from unitsleuth.catalog import Conversion
|
|
5
|
+
from unitsleuth.detect import UnivariateFit, detect, fit_conversion
|
|
6
|
+
from unitsleuth.multivariate import RowFit, detect_rows, repair
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Conversion",
|
|
12
|
+
"RowFit",
|
|
13
|
+
"UnivariateFit",
|
|
14
|
+
"catalog",
|
|
15
|
+
"detect",
|
|
16
|
+
"detect_rows",
|
|
17
|
+
"fit_conversion",
|
|
18
|
+
"repair",
|
|
19
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Dataframe adapters built on Narwhals (pandas, Polars, PyArrow, ...)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import narwhals.stable.v1 as nw
|
|
9
|
+
import numpy as np
|
|
10
|
+
from numpy.typing import ArrayLike, NDArray
|
|
11
|
+
|
|
12
|
+
FloatArray = NDArray[np.float64]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_frame(obj: Any) -> bool:
|
|
16
|
+
"""True for native eager dataframes that Narwhals understands."""
|
|
17
|
+
if isinstance(obj, Mapping):
|
|
18
|
+
return False
|
|
19
|
+
try:
|
|
20
|
+
nw.from_native(obj, eager_only=True)
|
|
21
|
+
except TypeError:
|
|
22
|
+
return False
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def columns_of(data: Any, names: list[str] | tuple[str, ...]) -> dict[str, ArrayLike]:
|
|
27
|
+
"""Extract named columns as float arrays from a mapping or a dataframe."""
|
|
28
|
+
if isinstance(data, Mapping):
|
|
29
|
+
return {n: data[n] for n in names}
|
|
30
|
+
frame = nw.from_native(data, eager_only=True)
|
|
31
|
+
missing = [n for n in names if n not in frame.columns]
|
|
32
|
+
if missing:
|
|
33
|
+
raise KeyError(f"columns not in data: {missing}")
|
|
34
|
+
out: dict[str, ArrayLike] = {}
|
|
35
|
+
for n in names:
|
|
36
|
+
s = frame[n].cast(nw.Float64)
|
|
37
|
+
out[n] = np.array(s.to_list(), dtype=np.float64)
|
|
38
|
+
# to_list maps nulls to None; np.array(..., float64) turns None into nan
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def replace_columns(data: Any, cols: Mapping[str, FloatArray]) -> Any:
|
|
43
|
+
"""Return a native dataframe of the same type with ``cols`` replaced."""
|
|
44
|
+
frame = nw.from_native(data, eager_only=True)
|
|
45
|
+
ns = nw.get_native_namespace(frame)
|
|
46
|
+
new = [
|
|
47
|
+
nw.new_series(name, arr.tolist(), nw.Float64, native_namespace=ns)
|
|
48
|
+
for name, arr in cols.items()
|
|
49
|
+
]
|
|
50
|
+
return frame.with_columns(*new).to_native()
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Catalog of unit conversions that unitsleuth can test for.
|
|
2
|
+
|
|
3
|
+
Every conversion is affine: ``x_to = scale * x_from + offset``. Multiplicative
|
|
4
|
+
conversions (``offset == 0``) cover most unit pairs; temperature needs the offset.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from numpy.typing import ArrayLike, NDArray
|
|
13
|
+
|
|
14
|
+
FloatArray = NDArray[np.float64]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Conversion:
|
|
19
|
+
"""An affine map between two units of the same quantity."""
|
|
20
|
+
|
|
21
|
+
quantity: str
|
|
22
|
+
from_unit: str
|
|
23
|
+
to_unit: str
|
|
24
|
+
scale: float
|
|
25
|
+
offset: float = 0.0
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
if not (np.isfinite(self.scale) and np.isfinite(self.offset)):
|
|
29
|
+
raise ValueError("scale and offset must be finite")
|
|
30
|
+
if self.scale == 0.0:
|
|
31
|
+
raise ValueError("scale must be non-zero")
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def name(self) -> str:
|
|
35
|
+
"""Short label such as ``'kg->lb'``."""
|
|
36
|
+
return f"{self.from_unit}->{self.to_unit}"
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def is_multiplicative(self) -> bool:
|
|
40
|
+
"""True when the conversion has no additive offset."""
|
|
41
|
+
return self.offset == 0.0
|
|
42
|
+
|
|
43
|
+
def forward(self, x: ArrayLike) -> FloatArray:
|
|
44
|
+
"""Convert values expressed in ``from_unit`` to ``to_unit``."""
|
|
45
|
+
arr = np.asarray(x, dtype=np.float64)
|
|
46
|
+
return np.asarray(self.scale * arr + self.offset, dtype=np.float64)
|
|
47
|
+
|
|
48
|
+
def inverse(self, y: ArrayLike) -> FloatArray:
|
|
49
|
+
"""Convert values expressed in ``to_unit`` back to ``from_unit``."""
|
|
50
|
+
arr = np.asarray(y, dtype=np.float64)
|
|
51
|
+
return np.asarray((arr - self.offset) / self.scale, dtype=np.float64)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _c(
|
|
55
|
+
quantity: str, from_unit: str, to_unit: str, scale: float, offset: float = 0.0
|
|
56
|
+
) -> Conversion:
|
|
57
|
+
return Conversion(quantity, from_unit, to_unit, scale, offset)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_CATALOG: tuple[Conversion, ...] = (
|
|
61
|
+
# length
|
|
62
|
+
_c("length", "m", "ft", 3.280839895013123),
|
|
63
|
+
_c("length", "cm", "in", 1.0 / 2.54),
|
|
64
|
+
_c("length", "km", "mi", 0.621371192237334),
|
|
65
|
+
_c("length", "m", "cm", 100.0),
|
|
66
|
+
_c("length", "ft", "in", 12.0),
|
|
67
|
+
# mass
|
|
68
|
+
_c("mass", "kg", "lb", 2.2046226218487757),
|
|
69
|
+
_c("mass", "kg", "g", 1000.0),
|
|
70
|
+
_c("mass", "lb", "oz", 16.0),
|
|
71
|
+
_c("mass", "g", "oz", 0.035273961949580414),
|
|
72
|
+
# temperature
|
|
73
|
+
_c("temperature", "degC", "degF", 1.8, 32.0),
|
|
74
|
+
_c("temperature", "degC", "K", 1.0, 273.15),
|
|
75
|
+
# volume
|
|
76
|
+
_c("volume", "L", "mL", 1000.0),
|
|
77
|
+
_c("volume", "L", "gal_us", 0.26417205235814845),
|
|
78
|
+
# pressure
|
|
79
|
+
_c("pressure", "kPa", "Pa", 1000.0),
|
|
80
|
+
_c("pressure", "kPa", "psi", 0.14503773773375),
|
|
81
|
+
_c("pressure", "mmHg", "kPa", 0.133322387415),
|
|
82
|
+
# speed
|
|
83
|
+
_c("speed", "km/h", "mph", 0.621371192237334),
|
|
84
|
+
_c("speed", "km/h", "m/s", 1.0 / 3.6),
|
|
85
|
+
# time
|
|
86
|
+
_c("time", "h", "min", 60.0),
|
|
87
|
+
_c("time", "min", "s", 60.0),
|
|
88
|
+
# proportions
|
|
89
|
+
_c("ratio", "fraction", "percent", 100.0),
|
|
90
|
+
# generic power-of-ten scale errors (classic keying/recording mistakes)
|
|
91
|
+
_c("generic", "x1", "x10", 10.0),
|
|
92
|
+
_c("generic", "x1", "x100", 100.0),
|
|
93
|
+
_c("generic", "x1", "x1000", 1000.0),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def conversions(quantity: str | None = None) -> tuple[Conversion, ...]:
|
|
98
|
+
"""Return the built-in conversions, optionally filtered by quantity."""
|
|
99
|
+
if quantity is None:
|
|
100
|
+
return _CATALOG
|
|
101
|
+
selected = tuple(c for c in _CATALOG if c.quantity == quantity)
|
|
102
|
+
if not selected:
|
|
103
|
+
raise KeyError(f"unknown quantity {quantity!r}; known: {quantities()}")
|
|
104
|
+
return selected
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def quantities() -> tuple[str, ...]:
|
|
108
|
+
"""Return the quantities covered by the catalog, in catalog order."""
|
|
109
|
+
return tuple(dict.fromkeys(c.quantity for c in _CATALOG))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get(from_unit: str, to_unit: str) -> Conversion:
|
|
113
|
+
"""Look up a built-in conversion by its unit names."""
|
|
114
|
+
for conv in _CATALOG:
|
|
115
|
+
if conv.from_unit == from_unit and conv.to_unit == to_unit:
|
|
116
|
+
return conv
|
|
117
|
+
raise KeyError(f"no built-in conversion {from_unit}->{to_unit}")
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Command-line interface: ``unitsleuth data.csv --col height:cm,in``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import csv
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from unitsleuth import catalog
|
|
13
|
+
from unitsleuth.catalog import Conversion
|
|
14
|
+
from unitsleuth.multivariate import detect_rows, repair
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _resolve(spec: str) -> tuple[str, Conversion]:
|
|
18
|
+
try:
|
|
19
|
+
name, units = spec.rsplit(":", 1)
|
|
20
|
+
a, b = (u.strip() for u in units.split(","))
|
|
21
|
+
except ValueError:
|
|
22
|
+
raise argparse.ArgumentTypeError(
|
|
23
|
+
f"bad --col {spec!r}; expected NAME:FROM,TO such as height:cm,in"
|
|
24
|
+
) from None
|
|
25
|
+
try:
|
|
26
|
+
return name, catalog.get(a, b)
|
|
27
|
+
except KeyError:
|
|
28
|
+
pass
|
|
29
|
+
try:
|
|
30
|
+
c = catalog.get(b, a)
|
|
31
|
+
except KeyError:
|
|
32
|
+
raise argparse.ArgumentTypeError(
|
|
33
|
+
f"no built-in conversion between {a!r} and {b!r}"
|
|
34
|
+
) from None
|
|
35
|
+
return name, Conversion(c.quantity, a, b, 1.0 / c.scale, -c.offset / c.scale)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _num(s: str) -> float:
|
|
39
|
+
try:
|
|
40
|
+
return float(s)
|
|
41
|
+
except ValueError:
|
|
42
|
+
return float("nan")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
46
|
+
p = argparse.ArgumentParser(
|
|
47
|
+
prog="unitsleuth",
|
|
48
|
+
description="Detect rows recorded in a different measurement unit.",
|
|
49
|
+
)
|
|
50
|
+
p.add_argument("csv", help="input CSV file with a header row")
|
|
51
|
+
p.add_argument(
|
|
52
|
+
"--col",
|
|
53
|
+
action="append",
|
|
54
|
+
required=True,
|
|
55
|
+
type=_resolve,
|
|
56
|
+
metavar="NAME:FROM,TO",
|
|
57
|
+
help="column and the conversion to test, e.g. weight:kg,lb (repeatable)",
|
|
58
|
+
)
|
|
59
|
+
p.add_argument("--out", help="write a repaired CSV to this path")
|
|
60
|
+
p.add_argument(
|
|
61
|
+
"--target",
|
|
62
|
+
choices=["majority", "base", "converted"],
|
|
63
|
+
default="majority",
|
|
64
|
+
help="unit to harmonise to when repairing (default: majority)",
|
|
65
|
+
)
|
|
66
|
+
p.add_argument("--min-delta-bic", type=float, default=10.0)
|
|
67
|
+
return p
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
71
|
+
"""Run the CLI. Exit code 1 means mixed units were detected."""
|
|
72
|
+
args = build_parser().parse_args(argv)
|
|
73
|
+
convs = dict(args.col)
|
|
74
|
+
with open(args.csv, newline="", encoding="utf-8") as fh:
|
|
75
|
+
reader = csv.DictReader(fh)
|
|
76
|
+
fields = list(reader.fieldnames or [])
|
|
77
|
+
rows = list(reader)
|
|
78
|
+
missing = [c for c in convs if c not in fields]
|
|
79
|
+
if missing:
|
|
80
|
+
print(f"error: columns not in file: {missing}", file=sys.stderr)
|
|
81
|
+
return 2
|
|
82
|
+
data = {c: np.array([_num(r[c]) for r in rows]) for c in convs}
|
|
83
|
+
try:
|
|
84
|
+
fit = detect_rows(data, convs, min_delta_bic=args.min_delta_bic)
|
|
85
|
+
except ValueError as exc:
|
|
86
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
87
|
+
return 2
|
|
88
|
+
print(fit.summary())
|
|
89
|
+
if args.out:
|
|
90
|
+
fixed = repair(data, fit, target=args.target)
|
|
91
|
+
for i, r in enumerate(rows):
|
|
92
|
+
for c in convs:
|
|
93
|
+
if np.isfinite(fixed[c][i]):
|
|
94
|
+
r[c] = repr(float(fixed[c][i]))
|
|
95
|
+
with open(args.out, "w", newline="", encoding="utf-8") as fh:
|
|
96
|
+
w = csv.DictWriter(fh, fieldnames=fields)
|
|
97
|
+
w.writeheader()
|
|
98
|
+
w.writerows(rows)
|
|
99
|
+
print(f"wrote repaired data to {args.out}")
|
|
100
|
+
return 1 if fit.detected else 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
raise SystemExit(main())
|