simulator 0.1.2
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.
- data/.gitignore +17 -0
- data/Gemfile +16 -0
- data/LICENSE +22 -0
- data/README.md +67 -0
- data/RELEASE.md +31 -0
- data/Rakefile +2 -0
- data/examples/drop/Gemfile +5 -0
- data/examples/drop/drop.png +0 -0
- data/examples/drop/drop.rb +70 -0
- data/examples/mortage/Gemfile +5 -0
- data/examples/mortage/mortgage.png +0 -0
- data/examples/mortage/mortgage.rb +91 -0
- data/features/equations.feature +17 -0
- data/features/physics.feature +22 -0
- data/features/savings_simulation.feature +17 -0
- data/features/step_definitions/equations_steps.rb +52 -0
- data/features/step_definitions/savings_simulation_steps.rb +40 -0
- data/lib/simulator.rb +14 -0
- data/lib/simulator/bound_variable.rb +18 -0
- data/lib/simulator/data.rb +23 -0
- data/lib/simulator/equation.rb +16 -0
- data/lib/simulator/model.rb +43 -0
- data/lib/simulator/period.rb +28 -0
- data/lib/simulator/run.rb +95 -0
- data/lib/simulator/sandbox.rb +31 -0
- data/lib/simulator/variable.rb +11 -0
- data/lib/simulator/variable_context.rb +66 -0
- data/lib/simulator/version.rb +3 -0
- data/simulator.gemspec +20 -0
- data/spec/beer_spec.rb +91 -0
- data/spec/context_spec.rb +38 -0
- data/spec/equation_spec.rb +24 -0
- data/spec/interest_spec.rb +79 -0
- data/spec/model_spec.rb +33 -0
- data/spec/period_spec.rb +28 -0
- data/spec/run_spec.rb +44 -0
- data/spec/sandbox_spec.rb +24 -0
- metadata +128 -0
@@ -0,0 +1,40 @@
|
|
1
|
+
require 'simulator'
|
2
|
+
include Simulator
|
3
|
+
|
4
|
+
Given /^a savings model$/ do
|
5
|
+
@model = Model.new
|
6
|
+
end
|
7
|
+
|
8
|
+
Given /^variable "([_\w]+)"$/ do |var_name|
|
9
|
+
@model.add_variable Variable.new var_name.to_sym
|
10
|
+
end
|
11
|
+
|
12
|
+
Given /^equation "(.*?)" bound to ([\w_]+)$/ do |eqtn_str, var_name|
|
13
|
+
var = @model.get_variable var_name.to_sym
|
14
|
+
eqtn = Equation.new var do
|
15
|
+
eval eqtn_str
|
16
|
+
end
|
17
|
+
@model.add_equation eqtn
|
18
|
+
end
|
19
|
+
|
20
|
+
Given /^the value (-?[\d\.]+) bound to ([\w_]+)$/ do |value, name|
|
21
|
+
@run.set name.to_sym => value.to_f
|
22
|
+
end
|
23
|
+
|
24
|
+
Given /^a new run of the model$/ do
|
25
|
+
@run = Run.new @model
|
26
|
+
end
|
27
|
+
|
28
|
+
When /^I step the run for (\d+) periods$/ do |period_count|
|
29
|
+
period_count.to_i.times do
|
30
|
+
@run.step
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
Then /^the value (-?[\d\.]+) should be bound to ([\w_]+)$/ do |expected, var_name|
|
35
|
+
actual = @run.variable_value var_name.to_sym
|
36
|
+
actual.should be_within(0.001).of expected.to_f
|
37
|
+
end
|
38
|
+
|
39
|
+
|
40
|
+
|
data/lib/simulator.rb
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
require "simulator/version"
|
2
|
+
require 'simulator/equation'
|
3
|
+
require 'simulator/variable'
|
4
|
+
require 'simulator/bound_variable'
|
5
|
+
require 'simulator/variable_context'
|
6
|
+
require 'simulator/sandbox'
|
7
|
+
require 'simulator/model'
|
8
|
+
require 'simulator/run'
|
9
|
+
require 'simulator/period'
|
10
|
+
require 'simulator/data'
|
11
|
+
|
12
|
+
module Simulator
|
13
|
+
end
|
14
|
+
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Simulator
|
2
|
+
# represents a variable bound to a context
|
3
|
+
class BoundVariable
|
4
|
+
attr_accessor :value
|
5
|
+
attr_reader :context, :variable
|
6
|
+
|
7
|
+
def initialize(variable, context)
|
8
|
+
@variable = variable
|
9
|
+
@value = @variable.default_value
|
10
|
+
@context = context
|
11
|
+
end
|
12
|
+
|
13
|
+
def name
|
14
|
+
@variable.name
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Simulator
|
2
|
+
class Data
|
3
|
+
attr_accessor :periods
|
4
|
+
def series(*var_names)
|
5
|
+
data = var_names.collect do |var_name|
|
6
|
+
@periods.collect do |period|
|
7
|
+
var = period.context.get var_name
|
8
|
+
var.value unless var.nil?
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
# If we just hae one series, then just return the first
|
13
|
+
var_names.length == 1 ? data.first : data
|
14
|
+
end
|
15
|
+
def series_with_headers(*var_names)
|
16
|
+
data = series *var_names
|
17
|
+
table = var_names.zip data
|
18
|
+
table.collect do |row|
|
19
|
+
row.flatten
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module Simulator
|
2
|
+
class Equation
|
3
|
+
attr_reader :variable
|
4
|
+
|
5
|
+
def initialize(var, &equation)
|
6
|
+
@equation_block = equation
|
7
|
+
@variable = var
|
8
|
+
end
|
9
|
+
|
10
|
+
# evaluate the equation in the passed context
|
11
|
+
def evaluate_in(context, periods = [])
|
12
|
+
sandbox = Sandbox.new context, periods
|
13
|
+
sandbox.instance_eval &@equation_block
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module Simulator
|
2
|
+
#
|
3
|
+
class Model
|
4
|
+
attr_accessor :name
|
5
|
+
attr_reader :equations
|
6
|
+
|
7
|
+
def initialize(&block)
|
8
|
+
@equations = []
|
9
|
+
@variables = {}
|
10
|
+
instance_eval &block unless block.nil?
|
11
|
+
end
|
12
|
+
def var(name, default_value = nil)
|
13
|
+
v = get_variable(name)
|
14
|
+
if v.nil?
|
15
|
+
v = Variable.new name, default_value
|
16
|
+
add_variable v
|
17
|
+
end
|
18
|
+
v
|
19
|
+
end
|
20
|
+
def eqtn(var_name, default_value = nil, &block)
|
21
|
+
v = var(var_name, default_value)
|
22
|
+
e = Equation.new(v, &block)
|
23
|
+
add_equation e
|
24
|
+
e
|
25
|
+
end
|
26
|
+
def add_equation(equation)
|
27
|
+
@equations << equation
|
28
|
+
end
|
29
|
+
def add_variable(variable)
|
30
|
+
@variables[variable.name] = variable
|
31
|
+
end
|
32
|
+
def get_variable(name)
|
33
|
+
@variables[name]
|
34
|
+
end
|
35
|
+
def variables
|
36
|
+
@variables.values
|
37
|
+
end
|
38
|
+
|
39
|
+
def new_run
|
40
|
+
Run.new self
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module Simulator
|
2
|
+
class Period
|
3
|
+
attr_reader :context
|
4
|
+
attr_reader :run
|
5
|
+
attr_reader :previous_periods
|
6
|
+
|
7
|
+
def initialize(run, previous_periods = [])
|
8
|
+
@run = run
|
9
|
+
@previous_periods = previous_periods
|
10
|
+
@context = @previous_periods.empty? ? VariableContext.new : previous.context.clone
|
11
|
+
end
|
12
|
+
|
13
|
+
# the first period
|
14
|
+
def first
|
15
|
+
@previous_periods.first
|
16
|
+
end
|
17
|
+
|
18
|
+
# the period before this one
|
19
|
+
def previous
|
20
|
+
@previous_periods.last
|
21
|
+
end
|
22
|
+
|
23
|
+
# returns a period count ago
|
24
|
+
def ago(count)
|
25
|
+
count == 0 ? self : @previous_periods[-count]
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
module Simulator
|
2
|
+
# A run of a model. It has its own context
|
3
|
+
class Run
|
4
|
+
attr_reader :model
|
5
|
+
attr_reader :periods
|
6
|
+
|
7
|
+
def initialize(model)
|
8
|
+
@model = model
|
9
|
+
@periods = [Period.new(self)]
|
10
|
+
current_context.add_variables *@model.variables
|
11
|
+
end
|
12
|
+
|
13
|
+
def evaluate
|
14
|
+
# evaluate the equations in the current context
|
15
|
+
@model.equations.each do | eqtn |
|
16
|
+
result = eqtn.evaluate_in current_context, @periods
|
17
|
+
var = eqtn.variable
|
18
|
+
bound_var = current_context.get(var.name)
|
19
|
+
bound_var.value = result
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def increment_period
|
24
|
+
p = Period.new self, @periods.dup
|
25
|
+
@periods << p
|
26
|
+
p
|
27
|
+
end
|
28
|
+
|
29
|
+
def step
|
30
|
+
evaluate
|
31
|
+
increment_period
|
32
|
+
end
|
33
|
+
|
34
|
+
# The number of periods so far. All runs have at least 1 period.
|
35
|
+
def period_count
|
36
|
+
@periods.length
|
37
|
+
end
|
38
|
+
|
39
|
+
# Returns the current_context, that is the variable context of the last
|
40
|
+
# period.
|
41
|
+
def current_context
|
42
|
+
@periods.last.context
|
43
|
+
end
|
44
|
+
|
45
|
+
def variables
|
46
|
+
current_context.variables
|
47
|
+
end
|
48
|
+
|
49
|
+
def set(var_hash)
|
50
|
+
current_context.set var_hash
|
51
|
+
end
|
52
|
+
|
53
|
+
def variable_value(var_name)
|
54
|
+
current_context.get(var_name).value
|
55
|
+
end
|
56
|
+
|
57
|
+
def summary
|
58
|
+
var_list = variables.sort_by(&:name).collect do |v|
|
59
|
+
"\t#{v.name} = #{v.value}"
|
60
|
+
end.join("\n")
|
61
|
+
model_summary = %Q{Model "#{@model.name}"}
|
62
|
+
period_summary = "Period #{period_count}"
|
63
|
+
|
64
|
+
"#{model_summary}\n#{period_summary}\nVariables:\n#{var_list}"
|
65
|
+
end
|
66
|
+
|
67
|
+
def variables_csv
|
68
|
+
vars = variables.sort_by(&:name)
|
69
|
+
var_names = vars.collect(&:name)
|
70
|
+
header = "'Period',#{var_names.collect{|n| "'#{n}'"}.join(',')}"
|
71
|
+
|
72
|
+
rows = []
|
73
|
+
@periods.each_index do |index|
|
74
|
+
period = @periods[index]
|
75
|
+
|
76
|
+
context = period.context
|
77
|
+
var_values = var_names.collect do | var_name |
|
78
|
+
var = context.get var_name
|
79
|
+
var.value
|
80
|
+
end.join(',')
|
81
|
+
|
82
|
+
rows << "#{index},#{var_values}"
|
83
|
+
end
|
84
|
+
|
85
|
+
"#{header}\n#{rows.join("\n")}"
|
86
|
+
end
|
87
|
+
|
88
|
+
def data
|
89
|
+
data = Data.new
|
90
|
+
data.periods = @periods
|
91
|
+
data
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module Simulator
|
2
|
+
class Sandbox
|
3
|
+
def initialize(context, periods = [])
|
4
|
+
@context = context
|
5
|
+
@periods = periods
|
6
|
+
end
|
7
|
+
|
8
|
+
private
|
9
|
+
|
10
|
+
def delay(var_name, var_periods)
|
11
|
+
throw 'First parameter must be a variable symbol' unless var_name.class == Symbol
|
12
|
+
|
13
|
+
# Get the period n steps back
|
14
|
+
period = @periods[-var_periods] # We might have to add 1 period back (- (var_periods+1))
|
15
|
+
context = period.context
|
16
|
+
var = context.get var_name
|
17
|
+
var.value unless var.nil?
|
18
|
+
end
|
19
|
+
|
20
|
+
def respond_to?(sym)
|
21
|
+
puts "Respond to: #{sym}"
|
22
|
+
not @context.get(sym.to_sym).nil?
|
23
|
+
end
|
24
|
+
|
25
|
+
def method_missing(method_name, *args, &block)
|
26
|
+
var = @context.get method_name.to_sym
|
27
|
+
return var.value unless var.nil?
|
28
|
+
super(method_name, *args, &block)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
module Simulator
|
2
|
+
class VariableContext
|
3
|
+
def initialize
|
4
|
+
@variables_hash = {}
|
5
|
+
end
|
6
|
+
|
7
|
+
def add(var_hash)
|
8
|
+
vars = var_hash.collect do |k, v|
|
9
|
+
Variable.new k
|
10
|
+
end
|
11
|
+
add_variables *vars
|
12
|
+
set var_hash
|
13
|
+
end
|
14
|
+
|
15
|
+
# add variables. doesn't check for uniqueness, does not overwrite existing
|
16
|
+
def add_variables(*vars)
|
17
|
+
# create bound variables for each variable
|
18
|
+
bound_vars = vars.collect do |v|
|
19
|
+
BoundVariable.new v, self
|
20
|
+
end
|
21
|
+
|
22
|
+
# add all the bound variables to the variables hash
|
23
|
+
keys = vars.collect(&:name)
|
24
|
+
append_hash = Hash[ keys.zip(bound_vars) ]
|
25
|
+
@variables_hash.merge!(append_hash) do |key, oldval, newval|
|
26
|
+
oldval # the first bound variable
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# Use to set the value for a variety of variables
|
31
|
+
def set(var_hash)
|
32
|
+
var_hash.each do |variable_name, value|
|
33
|
+
throw :MissingVariable unless @variables_hash.has_key? variable_name
|
34
|
+
|
35
|
+
bv = @variables_hash[variable_name]
|
36
|
+
bv.value = value
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def get(var_name)
|
41
|
+
@variables_hash[var_name]
|
42
|
+
end
|
43
|
+
|
44
|
+
def variables
|
45
|
+
@variables_hash.values
|
46
|
+
end
|
47
|
+
|
48
|
+
def unbound_variables
|
49
|
+
variables.collect(&:variable)
|
50
|
+
end
|
51
|
+
|
52
|
+
def clone
|
53
|
+
copy = VariableContext.new
|
54
|
+
copy.add_variables *unbound_variables
|
55
|
+
|
56
|
+
# create a value hash of variable names and values
|
57
|
+
value_hash = variables.inject({}) do |memo, v|
|
58
|
+
memo[v.name] = v.value
|
59
|
+
memo
|
60
|
+
end
|
61
|
+
copy.set value_hash
|
62
|
+
copy
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
data/simulator.gemspec
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
require File.expand_path('../lib/simulator/version', __FILE__)
|
3
|
+
|
4
|
+
Gem::Specification.new do |gem|
|
5
|
+
gem.authors = ["Jamie Ly"]
|
6
|
+
gem.email = ["me@jamie.ly"]
|
7
|
+
gem.description = %q{Use to simulate discrete time models. See the examples directory for examples.}
|
8
|
+
gem.summary = %q{Use to simulate discrete time models.}
|
9
|
+
gem.homepage = "http://github.com/jamiely/simulator"
|
10
|
+
|
11
|
+
gem.files = `git ls-files`.split($\)
|
12
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
13
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
14
|
+
gem.name = "simulator"
|
15
|
+
gem.require_paths = ["lib"]
|
16
|
+
gem.version = Simulator::VERSION
|
17
|
+
|
18
|
+
gem.add_development_dependency 'rspec'
|
19
|
+
gem.add_development_dependency 'cucumber'
|
20
|
+
end
|
data/spec/beer_spec.rb
ADDED
@@ -0,0 +1,91 @@
|
|
1
|
+
require 'simulator'
|
2
|
+
include Simulator
|
3
|
+
|
4
|
+
describe "a beer distribution simulation" do
|
5
|
+
before :each do
|
6
|
+
@model = Model.new do
|
7
|
+
name = "Beer Distribution"
|
8
|
+
|
9
|
+
var :time_to_ship, 1
|
10
|
+
var :vendor_orders, 10
|
11
|
+
eqtn :available_goods do
|
12
|
+
delay :vendor_orders, time_to_ship
|
13
|
+
end
|
14
|
+
|
15
|
+
eqtn :ordered_from_vendors do
|
16
|
+
if ordered_from_vendors.nil?
|
17
|
+
vendor_orders
|
18
|
+
else
|
19
|
+
delta = vendor_orders - available_goods
|
20
|
+
ordered_from_vendors + delta
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
var :time_to_build_product, 1
|
25
|
+
eqtn :wip_completion do
|
26
|
+
delay :available_goods, time_to_build_product
|
27
|
+
end
|
28
|
+
eqtn :work_in_progress do
|
29
|
+
if work_in_progress.nil?
|
30
|
+
available_goods
|
31
|
+
else
|
32
|
+
delta = available_goods - wip_completion
|
33
|
+
work_in_progress + delta
|
34
|
+
end
|
35
|
+
end
|
36
|
+
eqtn :finished_goods_inventory do
|
37
|
+
if finished_goods_inventory.nil?
|
38
|
+
wip_completion
|
39
|
+
else
|
40
|
+
delta = wip_completion - shipments
|
41
|
+
finished_goods_inventory + delta
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
var :new_customer_orders, 10 # this should be an equation later
|
46
|
+
eqtn :unfilled_orders do
|
47
|
+
[0, new_customer_orders - shipments].max
|
48
|
+
end
|
49
|
+
eqtn :total_customer_orders do
|
50
|
+
cumulative_unfilled_orders + new_customer_orders
|
51
|
+
end
|
52
|
+
eqtn :shipments, 0 do
|
53
|
+
[total_customer_orders, finished_goods_inventory].min
|
54
|
+
end
|
55
|
+
|
56
|
+
eqtn :inventory_coverage do
|
57
|
+
begin
|
58
|
+
finished_goods_inventory/total_customer_orders
|
59
|
+
rescue
|
60
|
+
0
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
var :fraction_of_unfilled_orders_that_are_cancelled, 0.25
|
65
|
+
eqtn :cancelled_unfilled_orders do
|
66
|
+
tmp = cumulative_unfilled_orders - filled_orders
|
67
|
+
tmp2 = tmp * fraction_of_unfilled_orders_that_are_cancelled
|
68
|
+
tmp2.round
|
69
|
+
end
|
70
|
+
|
71
|
+
eqtn :cumulative_unfilled_orders, 0 do
|
72
|
+
delta = unfilled_orders - filled_orders - cancelled_unfilled_orders
|
73
|
+
cumulative_unfilled_orders + delta
|
74
|
+
end
|
75
|
+
|
76
|
+
eqtn :filled_orders, 0 do
|
77
|
+
[0, shipments - new_customer_orders].max
|
78
|
+
end
|
79
|
+
eqtn :lost_orders, 0 do
|
80
|
+
lost_orders + cancelled_unfilled_orders
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
@run = Run.new @model
|
85
|
+
end
|
86
|
+
|
87
|
+
it "can step a run" do
|
88
|
+
@run.period_count.should eq 1
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|