Skip to content

Commit 82e7a4d

Browse files
author
Paul Prescod
committed
Add a summation feature.
1 parent c0b561d commit 82e7a4d

10 files changed

Lines changed: 414 additions & 6 deletions

File tree

docs/extending.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,8 @@ use `context.evaluate_raw()` instead of `context.evaluate()`.
413413

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

418419
```yaml
419420
# examples/sum_child_values.yml

docs/index.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1861,6 +1861,108 @@ Or:
18611861
twelve: ${Math.sqrt}
18621862
```
18631863

1864+
#### Rolling up numbers: `Math.random_partition`
1865+
1866+
Sometimes you want a parent object to have a field value which
1867+
is the sum of many child values. Snowfakery allow you to
1868+
specify or randomly generate the parent sum value and then
1869+
it will generate an appropriate number of children with
1870+
values that sum up to match it, using `Math.random_partition`:
1871+
1872+
```yaml
1873+
# examples/math_partition_simple.recipe.yml
1874+
- plugin: snowfakery.standard_plugins.Math
1875+
- object: ParentObject__c
1876+
count: 2
1877+
fields:
1878+
TotalAmount__c:
1879+
random_number:
1880+
min: 30
1881+
max: 90
1882+
friends:
1883+
- object: ChildObject__c
1884+
for_each:
1885+
var: child_value
1886+
value:
1887+
Math.random_partition:
1888+
total: ${{ParentObject__c.TotalAmount__c}}
1889+
fields:
1890+
Amount__c: ${{child_value}}
1891+
```
1892+
1893+
The `Math.random_partition` function splits up a number.
1894+
So this recipe might spit out the following
1895+
set of parents and children:
1896+
1897+
```json
1898+
ParentObject__c(id=1, TotalAmount__c=40)
1899+
ChildObject__c(id=1, Amount__c=3)
1900+
ChildObject__c(id=2, Amount__c=1)
1901+
ChildObject__c(id=3, Amount__c=24)
1902+
ChildObject__c(id=4, Amount__c=12)
1903+
ParentObject__c(id=2, TotalAmount__c=83)
1904+
ChildObject__c(id=5, Amount__c=2)
1905+
ChildObject__c(id=6, Amount__c=81)
1906+
```
1907+
1908+
There are 2 Parent objects created and a random number of
1909+
children per parent.
1910+
1911+
The `Math.random_partition`function takes argument
1912+
`min`, which is the smallest
1913+
value each part can have, `max`, which is the largest
1914+
possible value, `total` which is what all of the values
1915+
sum up to and `step` which is a number that each value
1916+
must have as a factor. E.g. if `step` is `4` then
1917+
values of `4`, `8`, `12` are valid.
1918+
1919+
For example:
1920+
1921+
```yaml
1922+
# examples/sum_simple_example.yml
1923+
- plugin: snowfakery.standard_plugins.Math
1924+
1925+
- object: Values
1926+
for_each:
1927+
var: current_value
1928+
value:
1929+
Math.random_partition:
1930+
total: 100
1931+
min: 10
1932+
max: 50
1933+
step: 5
1934+
fields:
1935+
Amount: ${{current_value}}
1936+
```
1937+
1938+
Which might generate `15,15,25,20,15,10` or `50,50` or `25,50,25`.
1939+
1940+
If `step` is a number smaller then `1`, then you can generate
1941+
pennies for numeric calculations. Valid values are `0.01` (penny
1942+
granularity), `0.05` (nickle), `0.10` (dime), `0.25` (quarter) and
1943+
`0.50` (half dollars). Other values are not supported.
1944+
1945+
```yaml
1946+
# examples/sum_pennies.yml
1947+
- plugin: snowfakery.standard_plugins.Math
1948+
1949+
- object: Values
1950+
for_each:
1951+
var: current_value
1952+
value:
1953+
Math.random_partition:
1954+
total: 100
1955+
min: 10
1956+
max: 50
1957+
step: 0.1
1958+
fields:
1959+
Amount: ${{current_value}}
1960+
```
1961+
1962+
It is possible to specify values which are inconsistent.
1963+
When that happens one of the constraints will be
1964+
violated.
1965+
18641966
### Advanced Unique IDs with the UniqueId plugin
18651967

18661968
There is a plugin which gives you more control over the generation of
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
- plugin: snowfakery.standard_plugins.Math
2+
- object: ParentObject__c
3+
count: 2
4+
fields:
5+
TotalAmount__c:
6+
random_number:
7+
min: 30
8+
max: 90
9+
friends:
10+
- object: ChildObject__c
11+
for_each:
12+
var: child_value
13+
value:
14+
Math.random_partition:
15+
total: ${{ParentObject__c.TotalAmount__c}}
16+
fields:
17+
Amount__c: ${{child_value}}

examples/sum_pennies.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
- plugin: snowfakery.standard_plugins.Math
2+
3+
- object: Values
4+
for_each:
5+
var: current_value
6+
value:
7+
Math.random_partition:
8+
total: 100
9+
min: 10
10+
max: 50
11+
step: 0.1
12+
fields:
13+
Amount: ${{current_value}}

examples/sum_pennies_param.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
- plugin: snowfakery.standard_plugins.Math
2+
- option: step
3+
default: 0.01
4+
5+
- object: Values
6+
for_each:
7+
var: current_value
8+
value:
9+
Math.random_partition:
10+
total: 100
11+
min: 10
12+
max: 50
13+
step: ${{step}}
14+
fields:
15+
Amount: ${{current_value}}

examples/sum_plugin_example.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# This shows how you could create a plugin or feature where
2+
# a parent object generates child objects which sum up
3+
# to any particular value.
4+
5+
- plugin: examples.sum_totals.SummationPlugin
6+
- var: summation_helper
7+
value:
8+
SummationPlugin.summer:
9+
total: 100
10+
step: 10
11+
12+
- object: ParentObject__c
13+
count: 10
14+
fields:
15+
MinimumChildObjectAmount__c: 10
16+
MinimumStep: 5
17+
TotalAmount__c: ${{summation_helper.total}}
18+
friends:
19+
- object: ChildObject__c
20+
count: ${{summation_helper.count}}
21+
fields:
22+
Parent__c:
23+
reference: ParentObject__c
24+
Amount__c: ${{summation_helper.next_amount}}
25+
RunningTotal__c: ${{summation_helper.running_total}}

examples/sum_simple_example.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
- plugin: snowfakery.standard_plugins.Math
2+
3+
- object: Values
4+
for_each:
5+
var: current_value
6+
value:
7+
Math.random_partition:
8+
total: 100
9+
min: 10
10+
max: 50
11+
step: 5
12+
fields:
13+
Amount: ${{current_value}}

schema/snowfakery_recipe.jsonschema.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@
6161
}
6262
]
6363
},
64+
"for_each": {
65+
"type": "object",
66+
"anyOf": [
67+
{
68+
"$ref": "#/$defs/var"
69+
}
70+
]
71+
},
6472
"fields": {
6573
"type": "object",
6674
"additionalProperties": true
Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,105 @@
11
import math
2-
from snowfakery.plugins import SnowfakeryPlugin
2+
from random import randint, shuffle
3+
from types import SimpleNamespace
4+
from typing import List, Optional, Union
5+
from snowfakery.plugins import SnowfakeryPlugin, memorable, PluginResultIterator
36

47

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

9-
class MathNamespace:
10-
pass
12+
class MathNamespace(SimpleNamespace):
13+
@memorable
14+
def random_partition(
15+
self,
16+
total: int,
17+
*,
18+
min: int = 1,
19+
max: Optional[int] = None,
20+
step: int = 1,
21+
):
22+
return GenericPluginResultIterator(False, parts(total, min, max, step))
1123

1224
mathns = MathNamespace()
13-
mathns.__dict__ = math.__dict__.copy()
25+
mathns.__dict__.update(math.__dict__.copy())
1426

1527
mathns.pi = math.pi
1628
mathns.round = round
1729
mathns.min = min
1830
mathns.max = max
19-
31+
mathns.context = self.context
2032
return mathns
33+
34+
35+
class GenericPluginResultIterator(PluginResultIterator):
36+
def __init__(self, repeat, iterable):
37+
super().__init__(repeat)
38+
self.next = iter(iterable).__next__
39+
40+
41+
def parts(total: int, min_: int = 1, max_=None, step=1) -> List[Union[int, float]]:
42+
"""Split a number into a randomized set of 'pieces'.
43+
The pieces add up to the `total`. E.g.
44+
45+
parts(12) -> [3, 6, 3]
46+
parts(16) -> [8, 4, 2, 2]
47+
48+
The numbers generated will never be less than `min_`, if provided.
49+
50+
The numbers generated will never be less than `max_`, if provided.
51+
52+
The numbers generated will always be a multiple of `step`, if provided.
53+
54+
But...if you provide inconsistent constraints then your values
55+
will be inconsistent with them. e.g. if `total` is not a multiple
56+
of `step`.
57+
"""
58+
max_ = max_ or total
59+
factor = 0
60+
61+
if step < 1:
62+
assert step in [0.01, 0.5, 0.1, 0.20, 0.25, 0.50], step
63+
factor = step
64+
total = int(total / factor)
65+
step = int(total / factor)
66+
min_ = int(total / factor)
67+
max_ = int(total / factor)
68+
69+
pieces = []
70+
71+
while sum(pieces) < total:
72+
remaining = total - sum(pieces)
73+
smallest = max(min_, step)
74+
if remaining < smallest:
75+
# try to add it to a random other piece
76+
for i, val in enumerate(pieces):
77+
if val + remaining <= max_:
78+
pieces[i] += remaining
79+
remaining = 0
80+
break
81+
82+
# just tack it on the end despite
83+
# it being too small...our
84+
# constraints must have been impossible
85+
# to fulfil
86+
if remaining:
87+
pieces.append(remaining)
88+
89+
else:
90+
part = randint(smallest, min(remaining, max_))
91+
round_up = part + step - (part % step)
92+
if round_up <= min(remaining, max_) and randint(0, 1):
93+
part = round_up
94+
else:
95+
part -= part % step
96+
97+
pieces.append(part)
98+
99+
assert sum(pieces) == total, pieces
100+
assert 0 not in pieces, pieces
101+
102+
shuffle(pieces)
103+
if factor:
104+
pieces = [round(p * factor, 2) for p in pieces]
105+
return pieces

0 commit comments

Comments
 (0)