Skip to content

Commit 28ee6a7

Browse files
authored
Merge pull request #412 from posit-dev/feat-float-field-precision
feat: add `float_field()` `precision=` parameter
2 parents ac1ac9b + 2734bad commit 28ee6a7

4 files changed

Lines changed: 100 additions & 5 deletions

File tree

pointblank/field.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,7 @@ class FloatField(Field):
490490
min_val: float | None = None
491491
max_val: float | None = None
492492
allowed: list[float] | None = field(default=None)
493+
precision: int | None = None
493494

494495
# Override dtype with default
495496
dtype: str = "Float64"
@@ -517,6 +518,10 @@ def _validate(self) -> None:
517518
if len(self.allowed) == 0:
518519
raise ValueError("allowed list cannot be empty")
519520

521+
# Validate precision
522+
if self.precision is not None and self.precision < 0:
523+
raise ValueError(f"precision ({self.precision}) must be a non-negative integer")
524+
520525
def has_allowed_values(self) -> bool:
521526
"""Check if this field has a set of allowed values."""
522527
return self.allowed is not None
@@ -526,6 +531,7 @@ def float_field(
526531
min_val: float | None = None,
527532
max_val: float | None = None,
528533
allowed: list[float] | None = None,
534+
precision: int | None = None,
529535
nullable: bool = False,
530536
null_probability: float = 0.0,
531537
unique: bool = False,
@@ -555,6 +561,9 @@ def float_field(
555561
allowed
556562
List of allowed values (categorical constraint). When provided, values are sampled from
557563
this list. Cannot be combined with `min_val=`/`max_val=`.
564+
precision
565+
Number of decimal places to round generated values to. Default is `None` (no rounding).
566+
Must be a non-negative integer. Has no effect when `allowed=` or `generator=` is used.
558567
nullable
559568
Whether the column can contain null values. Default is `False`.
560569
null_probability
@@ -578,8 +587,8 @@ def float_field(
578587
------
579588
ValueError
580589
If `min_val` is greater than `max_val`, if `allowed` is an empty list, if
581-
`null_probability` is not between `0.0` and `1.0`, or if `dtype` is not a valid
582-
float type.
590+
`null_probability` is not between `0.0` and `1.0`, if `precision` is negative,
591+
or if `dtype` is not a valid float type.
583592
584593
Examples
585594
--------
@@ -620,7 +629,20 @@ def float_field(
620629
calibration=pb.float_field(min_val=0.9, max_val=1.1),
621630
)
622631
623-
pb.preview(pb.generate_dataset(schema, n=30, seed=7))
632+
pb.preview(pb.generate_dataset(schema, n=30, seed=23))
633+
```
634+
635+
Use `precision=` to round generated values to a fixed number of decimal places. This is useful
636+
for prices, scores, or any measurement where full floating-point precision is unwanted:
637+
638+
```{python}
639+
schema = pb.Schema(
640+
price=pb.float_field(min_val=1.0, max_val=200.0, precision=2),
641+
score=pb.float_field(min_val=0.0, max_val=100.0, precision=1),
642+
probability=pb.float_field(min_val=0.0, max_val=1.0, precision=4),
643+
)
644+
645+
pb.preview(pb.generate_dataset(schema, n=20, seed=23))
624646
```
625647
626648
Setting `dtype="Float32"` gives reduced precision, and a custom `generator=` provides
@@ -636,13 +658,14 @@ def float_field(
636658
log_value=pb.float_field(generator=lambda: math.log(rng.uniform(1, 1000))),
637659
)
638660
639-
pb.preview(pb.generate_dataset(schema, n=20, seed=99))
661+
pb.preview(pb.generate_dataset(schema, n=20, seed=23))
640662
```
641663
"""
642664
return FloatField(
643665
min_val=min_val,
644666
max_val=max_val,
645667
allowed=allowed,
668+
precision=precision,
646669
nullable=nullable,
647670
null_probability=null_probability,
648671
unique=unique,

pointblank/generate/generators.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,17 @@ def _generate_float(field: Field, rng: random.Random, generator: Any | None = No
6464
"""Generate a random float value respecting field constraints."""
6565
min_val = getattr(field, "min_val", None)
6666
max_val = getattr(field, "max_val", None)
67+
precision = getattr(field, "precision", None)
6768

6869
min_val = min_val if min_val is not None else -1e10
6970
max_val = max_val if max_val is not None else 1e10
7071

71-
return rng.uniform(float(min_val), float(max_val))
72+
value = rng.uniform(float(min_val), float(max_val))
73+
74+
if precision is not None:
75+
value = round(value, precision)
76+
77+
return value
7278

7379

7480
def _generate_string(

tests/test_field.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,26 @@ def test_float_field_is_numeric(self):
121121
assert field.is_float() is True
122122
assert field.is_integer() is False
123123

124+
def test_float_field_with_precision(self):
125+
"""Test FloatField with precision parameter."""
126+
field = float_field(min_val=0.0, max_val=100.0, precision=2)
127+
assert field.precision == 2
128+
129+
def test_float_field_precision_none_by_default(self):
130+
"""Test that precision defaults to None."""
131+
field = float_field()
132+
assert field.precision is None
133+
134+
def test_float_field_precision_zero_is_valid(self):
135+
"""Test that `precision=0` is accepted (rounds to integer)."""
136+
field = float_field(min_val=0.0, max_val=10.0, precision=0)
137+
assert field.precision == 0
138+
139+
def test_float_field_negative_precision_raises_error(self):
140+
"""Test that negative precision raises ValueError."""
141+
with pytest.raises(ValueError, match="precision"):
142+
float_field(precision=-1)
143+
124144

125145
class TestStringField:
126146
"""Tests for StringField and string_field()."""

tests/test_generate.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,52 @@ def test_generate_float64_with_constraints(self):
105105

106106
assert all(0.0 <= v <= 1.0 for v in values)
107107

108+
def test_generate_float_with_precision(self):
109+
"""Test that precision rounds generated float values."""
110+
field = float_field(min_val=0.0, max_val=100.0, precision=2)
111+
config = GeneratorConfig(n=50, seed=23)
112+
values = generate_column(field, config)
113+
114+
assert all(isinstance(v, float) for v in values)
115+
assert all(round(v, 2) == v for v in values)
116+
117+
def test_generate_float_precision_zero(self):
118+
"""Test that `precision=0` rounds values to whole numbers."""
119+
field = float_field(min_val=0.0, max_val=100.0, precision=0)
120+
config = GeneratorConfig(n=20, seed=7)
121+
values = generate_column(field, config)
122+
123+
assert all(v == round(v, 0) for v in values)
124+
125+
def test_generate_float_precision_none_produces_full_precision(self):
126+
"""Test that `precision=None` does not round values."""
127+
field = float_field(min_val=0.0, max_val=1.0)
128+
config = GeneratorConfig(n=20, seed=23)
129+
values = generate_column(field, config)
130+
131+
# Full-precision floats are very unlikely to already be rounded to 2 decimals
132+
assert not all(round(v, 2) == v for v in values)
133+
134+
def test_generate_float_precision_with_unique(self):
135+
"""Test that precision composes correctly with unique=True."""
136+
field = float_field(min_val=1.0, max_val=200.0, precision=2, unique=True)
137+
config = GeneratorConfig(n=20, seed=23)
138+
values = generate_column(field, config)
139+
140+
assert len(values) == len(set(values))
141+
assert all(round(v, 2) == v for v in values)
142+
143+
def test_generate_float_precision_with_nullable(self):
144+
"""Test that precision composes correctly with nullable."""
145+
field = float_field(
146+
min_val=0.0, max_val=10.0, precision=1, nullable=True, null_probability=0.3
147+
)
148+
config = GeneratorConfig(n=50, seed=99)
149+
values = generate_column(field, config)
150+
151+
non_null = [v for v in values if v is not None]
152+
assert all(round(v, 1) == v for v in non_null)
153+
108154

109155
class TestGenerateColumnString:
110156
"""Tests for string column generation."""

0 commit comments

Comments
 (0)