Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions pointblank/field.py
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,7 @@ class FloatField(Field):
min_val: float | None = None
max_val: float | None = None
allowed: list[float] | None = field(default=None)
precision: int | None = None

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

# Validate precision
if self.precision is not None and self.precision < 0:
raise ValueError(f"precision ({self.precision}) must be a non-negative integer")

def has_allowed_values(self) -> bool:
"""Check if this field has a set of allowed values."""
return self.allowed is not None
Expand All @@ -526,6 +531,7 @@ def float_field(
min_val: float | None = None,
max_val: float | None = None,
allowed: list[float] | None = None,
precision: int | None = None,
nullable: bool = False,
null_probability: float = 0.0,
unique: bool = False,
Expand Down Expand Up @@ -555,6 +561,9 @@ def float_field(
allowed
List of allowed values (categorical constraint). When provided, values are sampled from
this list. Cannot be combined with `min_val=`/`max_val=`.
precision
Number of decimal places to round generated values to. Default is `None` (no rounding).
Must be a non-negative integer. Has no effect when `allowed=` or `generator=` is used.
nullable
Whether the column can contain null values. Default is `False`.
null_probability
Expand All @@ -578,8 +587,8 @@ def float_field(
------
ValueError
If `min_val` is greater than `max_val`, if `allowed` is an empty list, if
`null_probability` is not between `0.0` and `1.0`, or if `dtype` is not a valid
float type.
`null_probability` is not between `0.0` and `1.0`, if `precision` is negative,
or if `dtype` is not a valid float type.

Examples
--------
Expand Down Expand Up @@ -620,7 +629,20 @@ def float_field(
calibration=pb.float_field(min_val=0.9, max_val=1.1),
)

pb.preview(pb.generate_dataset(schema, n=30, seed=7))
pb.preview(pb.generate_dataset(schema, n=30, seed=23))
```

Use `precision=` to round generated values to a fixed number of decimal places. This is useful
for prices, scores, or any measurement where full floating-point precision is unwanted:

```{python}
schema = pb.Schema(
price=pb.float_field(min_val=1.0, max_val=200.0, precision=2),
score=pb.float_field(min_val=0.0, max_val=100.0, precision=1),
probability=pb.float_field(min_val=0.0, max_val=1.0, precision=4),
)

pb.preview(pb.generate_dataset(schema, n=20, seed=23))
```

Setting `dtype="Float32"` gives reduced precision, and a custom `generator=` provides
Expand All @@ -636,13 +658,14 @@ def float_field(
log_value=pb.float_field(generator=lambda: math.log(rng.uniform(1, 1000))),
)

pb.preview(pb.generate_dataset(schema, n=20, seed=99))
pb.preview(pb.generate_dataset(schema, n=20, seed=23))
```
"""
return FloatField(
min_val=min_val,
max_val=max_val,
allowed=allowed,
precision=precision,
nullable=nullable,
null_probability=null_probability,
unique=unique,
Expand Down
8 changes: 7 additions & 1 deletion pointblank/generate/generators.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,17 @@ def _generate_float(field: Field, rng: random.Random, generator: Any | None = No
"""Generate a random float value respecting field constraints."""
min_val = getattr(field, "min_val", None)
max_val = getattr(field, "max_val", None)
precision = getattr(field, "precision", None)

min_val = min_val if min_val is not None else -1e10
max_val = max_val if max_val is not None else 1e10

return rng.uniform(float(min_val), float(max_val))
value = rng.uniform(float(min_val), float(max_val))

if precision is not None:
value = round(value, precision)

return value


def _generate_string(
Expand Down
20 changes: 20 additions & 0 deletions tests/test_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,26 @@ def test_float_field_is_numeric(self):
assert field.is_float() is True
assert field.is_integer() is False

def test_float_field_with_precision(self):
"""Test FloatField with precision parameter."""
field = float_field(min_val=0.0, max_val=100.0, precision=2)
assert field.precision == 2

def test_float_field_precision_none_by_default(self):
"""Test that precision defaults to None."""
field = float_field()
assert field.precision is None

def test_float_field_precision_zero_is_valid(self):
"""Test that `precision=0` is accepted (rounds to integer)."""
field = float_field(min_val=0.0, max_val=10.0, precision=0)
assert field.precision == 0

def test_float_field_negative_precision_raises_error(self):
"""Test that negative precision raises ValueError."""
with pytest.raises(ValueError, match="precision"):
float_field(precision=-1)


class TestStringField:
"""Tests for StringField and string_field()."""
Expand Down
46 changes: 46 additions & 0 deletions tests/test_generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,52 @@ def test_generate_float64_with_constraints(self):

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

def test_generate_float_with_precision(self):
"""Test that precision rounds generated float values."""
field = float_field(min_val=0.0, max_val=100.0, precision=2)
config = GeneratorConfig(n=50, seed=23)
values = generate_column(field, config)

assert all(isinstance(v, float) for v in values)
assert all(round(v, 2) == v for v in values)

def test_generate_float_precision_zero(self):
"""Test that `precision=0` rounds values to whole numbers."""
field = float_field(min_val=0.0, max_val=100.0, precision=0)
config = GeneratorConfig(n=20, seed=7)
values = generate_column(field, config)

assert all(v == round(v, 0) for v in values)

def test_generate_float_precision_none_produces_full_precision(self):
"""Test that `precision=None` does not round values."""
field = float_field(min_val=0.0, max_val=1.0)
config = GeneratorConfig(n=20, seed=23)
values = generate_column(field, config)

# Full-precision floats are very unlikely to already be rounded to 2 decimals
assert not all(round(v, 2) == v for v in values)

def test_generate_float_precision_with_unique(self):
"""Test that precision composes correctly with unique=True."""
field = float_field(min_val=1.0, max_val=200.0, precision=2, unique=True)
config = GeneratorConfig(n=20, seed=23)
values = generate_column(field, config)

assert len(values) == len(set(values))
assert all(round(v, 2) == v for v in values)

def test_generate_float_precision_with_nullable(self):
"""Test that precision composes correctly with nullable."""
field = float_field(
min_val=0.0, max_val=10.0, precision=1, nullable=True, null_probability=0.3
)
config = GeneratorConfig(n=50, seed=99)
values = generate_column(field, config)

non_null = [v for v in values if v is not None]
assert all(round(v, 1) == v for v in non_null)


class TestGenerateColumnString:
"""Tests for string column generation."""
Expand Down
Loading