Skip to content

Commit

Permalink
Add a summation feature.
Browse files Browse the repository at this point in the history
  • Loading branch information
Paul Prescod committed Dec 23, 2022
1 parent c0b561d commit 82e7a4d
Show file tree
Hide file tree
Showing 10 changed files with 414 additions and 6 deletions.
3 changes: 2 additions & 1 deletion docs/extending.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,8 @@ use `context.evaluate_raw()` instead of `context.evaluate()`.

Plugins that require "memory" or "state" are possible using `PluginResult`
objects or subclasses. Consider a plugin that generates child objects
that include values that sum up values on child objects to a value specified on a parent:
that include values that sum up values on child objects to a value specified on a parent (similar to a simple version
of `Math.random_partition`):

```yaml
# examples/sum_child_values.yml
Expand Down
102 changes: 102 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -1861,6 +1861,108 @@ Or:
twelve: ${Math.sqrt}
```

#### Rolling up numbers: `Math.random_partition`

Sometimes you want a parent object to have a field value which
is the sum of many child values. Snowfakery allow you to
specify or randomly generate the parent sum value and then
it will generate an appropriate number of children with
values that sum up to match it, using `Math.random_partition`:

```yaml
# examples/math_partition_simple.recipe.yml
- plugin: snowfakery.standard_plugins.Math
- object: ParentObject__c
count: 2
fields:
TotalAmount__c:
random_number:
min: 30
max: 90
friends:
- object: ChildObject__c
for_each:
var: child_value
value:
Math.random_partition:
total: ${{ParentObject__c.TotalAmount__c}}
fields:
Amount__c: ${{child_value}}
```

The `Math.random_partition` function splits up a number.
So this recipe might spit out the following
set of parents and children:

```json
ParentObject__c(id=1, TotalAmount__c=40)
ChildObject__c(id=1, Amount__c=3)
ChildObject__c(id=2, Amount__c=1)
ChildObject__c(id=3, Amount__c=24)
ChildObject__c(id=4, Amount__c=12)
ParentObject__c(id=2, TotalAmount__c=83)
ChildObject__c(id=5, Amount__c=2)
ChildObject__c(id=6, Amount__c=81)
```

There are 2 Parent objects created and a random number of
children per parent.

The `Math.random_partition`function takes argument
`min`, which is the smallest
value each part can have, `max`, which is the largest
possible value, `total` which is what all of the values
sum up to and `step` which is a number that each value
must have as a factor. E.g. if `step` is `4` then
values of `4`, `8`, `12` are valid.

For example:

```yaml
# examples/sum_simple_example.yml
- plugin: snowfakery.standard_plugins.Math
- object: Values
for_each:
var: current_value
value:
Math.random_partition:
total: 100
min: 10
max: 50
step: 5
fields:
Amount: ${{current_value}}
```

Which might generate `15,15,25,20,15,10` or `50,50` or `25,50,25`.

If `step` is a number smaller then `1`, then you can generate
pennies for numeric calculations. Valid values are `0.01` (penny
granularity), `0.05` (nickle), `0.10` (dime), `0.25` (quarter) and
`0.50` (half dollars). Other values are not supported.

```yaml
# examples/sum_pennies.yml
- plugin: snowfakery.standard_plugins.Math
- object: Values
for_each:
var: current_value
value:
Math.random_partition:
total: 100
min: 10
max: 50
step: 0.1
fields:
Amount: ${{current_value}}
```

It is possible to specify values which are inconsistent.
When that happens one of the constraints will be
violated.

### Advanced Unique IDs with the UniqueId plugin

There is a plugin which gives you more control over the generation of
Expand Down
17 changes: 17 additions & 0 deletions examples/math_partition_simple.recipe.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
- plugin: snowfakery.standard_plugins.Math
- object: ParentObject__c
count: 2
fields:
TotalAmount__c:
random_number:
min: 30
max: 90
friends:
- object: ChildObject__c
for_each:
var: child_value
value:
Math.random_partition:
total: ${{ParentObject__c.TotalAmount__c}}
fields:
Amount__c: ${{child_value}}
13 changes: 13 additions & 0 deletions examples/sum_pennies.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
- plugin: snowfakery.standard_plugins.Math

- object: Values
for_each:
var: current_value
value:
Math.random_partition:
total: 100
min: 10
max: 50
step: 0.1
fields:
Amount: ${{current_value}}
15 changes: 15 additions & 0 deletions examples/sum_pennies_param.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
- plugin: snowfakery.standard_plugins.Math
- option: step
default: 0.01

- object: Values
for_each:
var: current_value
value:
Math.random_partition:
total: 100
min: 10
max: 50
step: ${{step}}
fields:
Amount: ${{current_value}}
25 changes: 25 additions & 0 deletions examples/sum_plugin_example.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# This shows how you could create a plugin or feature where
# a parent object generates child objects which sum up
# to any particular value.

- plugin: examples.sum_totals.SummationPlugin
- var: summation_helper
value:
SummationPlugin.summer:
total: 100
step: 10

- object: ParentObject__c
count: 10
fields:
MinimumChildObjectAmount__c: 10
MinimumStep: 5
TotalAmount__c: ${{summation_helper.total}}
friends:
- object: ChildObject__c
count: ${{summation_helper.count}}
fields:
Parent__c:
reference: ParentObject__c
Amount__c: ${{summation_helper.next_amount}}
RunningTotal__c: ${{summation_helper.running_total}}
13 changes: 13 additions & 0 deletions examples/sum_simple_example.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
- plugin: snowfakery.standard_plugins.Math

- object: Values
for_each:
var: current_value
value:
Math.random_partition:
total: 100
min: 10
max: 50
step: 5
fields:
Amount: ${{current_value}}
8 changes: 8 additions & 0 deletions schema/snowfakery_recipe.jsonschema.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@
}
]
},
"for_each": {
"type": "object",
"anyOf": [
{
"$ref": "#/$defs/var"
}
]
},
"fields": {
"type": "object",
"additionalProperties": true
Expand Down
95 changes: 90 additions & 5 deletions snowfakery/standard_plugins/_math.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,105 @@
import math
from snowfakery.plugins import SnowfakeryPlugin
from random import randint, shuffle
from types import SimpleNamespace
from typing import List, Optional, Union
from snowfakery.plugins import SnowfakeryPlugin, memorable, PluginResultIterator


class Math(SnowfakeryPlugin):
def custom_functions(self, *args, **kwargs):
"Expose math functions to Snowfakery"

class MathNamespace:
pass
class MathNamespace(SimpleNamespace):
@memorable
def random_partition(
self,
total: int,
*,
min: int = 1,
max: Optional[int] = None,
step: int = 1,
):
return GenericPluginResultIterator(False, parts(total, min, max, step))

mathns = MathNamespace()
mathns.__dict__ = math.__dict__.copy()
mathns.__dict__.update(math.__dict__.copy())

mathns.pi = math.pi
mathns.round = round
mathns.min = min
mathns.max = max

mathns.context = self.context
return mathns


class GenericPluginResultIterator(PluginResultIterator):
def __init__(self, repeat, iterable):
super().__init__(repeat)
self.next = iter(iterable).__next__


def parts(total: int, min_: int = 1, max_=None, step=1) -> List[Union[int, float]]:
"""Split a number into a randomized set of 'pieces'.
The pieces add up to the `total`. E.g.
parts(12) -> [3, 6, 3]
parts(16) -> [8, 4, 2, 2]
The numbers generated will never be less than `min_`, if provided.
The numbers generated will never be less than `max_`, if provided.
The numbers generated will always be a multiple of `step`, if provided.
But...if you provide inconsistent constraints then your values
will be inconsistent with them. e.g. if `total` is not a multiple
of `step`.
"""
max_ = max_ or total
factor = 0

if step < 1:
assert step in [0.01, 0.5, 0.1, 0.20, 0.25, 0.50], step
factor = step
total = int(total / factor)
step = int(total / factor)
min_ = int(total / factor)
max_ = int(total / factor)

pieces = []

while sum(pieces) < total:
remaining = total - sum(pieces)
smallest = max(min_, step)
if remaining < smallest:
# try to add it to a random other piece
for i, val in enumerate(pieces):
if val + remaining <= max_:
pieces[i] += remaining
remaining = 0
break

# just tack it on the end despite
# it being too small...our
# constraints must have been impossible
# to fulfil
if remaining:
pieces.append(remaining)

else:
part = randint(smallest, min(remaining, max_))
round_up = part + step - (part % step)
if round_up <= min(remaining, max_) and randint(0, 1):
part = round_up
else:
part -= part % step

pieces.append(part)

assert sum(pieces) == total, pieces
assert 0 not in pieces, pieces

shuffle(pieces)
if factor:
pieces = [round(p * factor, 2) for p in pieces]
return pieces
Loading

0 comments on commit 82e7a4d

Please sign in to comment.