module RubyLabs
=begin rdoc
== SphereLab
The SphereLab module has definitions of classes and methods used in the projects for Chapter 11
of Explorations in Computing. The module includes a simple "turtle graphics" module
for experiments with moving objects, definitions of
Vector and Body objects used for n-body simulations, and methods for updating a system of
bodies based on the gravitational forces acting between pairs of bodies.
=end
module SphereLab
# These class variables maintain the state of the display and other miscellaneous
# global values.
@@sphereDirectory = File.join(File.dirname(__FILE__), '..', 'data', 'spheres')
@@viewerOptions = {
:dotColor => '#000080',
:dotRadius => 1.0,
:origin => :center,
}
@@droppingOptions = {
:canvasSize => 400,
:mxmin => 100,
:mymin => 50,
:hmax => 100,
:dash => 1,
}
@@robotOptions = {
:canvasSize => 400,
:polygon => [4,0,0,10,4,8,8,10],
}
@@drawing = nil
@@delay = 0.1
NBodyView = Struct.new(:bodies, :origin, :scale, :options)
TurtleView = Struct.new(:turtle, :options)
MelonView = Struct.new(:bodies, :scale, :ground, :startloc, :options)
=begin rdoc
The universal gravitational constant, assuming mass is
in units of kilograms, distances are in meters, and time is in seconds.
=end
G = 6.67E-11
=begin rdoc
== Vector
A Vector is a 3-tuple of (x,y,z) coordinates. Operators defined for
vector objects (where +v+ is a vector and +a+ is a scalar) are:
v == v
v + v
v - v
v * a
v.add(v) (equivalent to v += v)
v.sub(v) (equivalent to v -= v)
v.scale(a) (equivalent to v *= a)
v.norm
Arithmetic methods are invoked for a vector v1 when Ruby evaluates an expression of the
form v1 v2 where is +, -, or *. They create a a new vector containing the
result of the operation. + and - do element-wise addition or and subtraction, *
is a vector-scalar multiplication.
The add, sub, and scale methods modify a vector, e.g. a call to v1.add(v2) will update
v1 by adding the corresponding components in v2.
=end
class Vector
attr_accessor :x, :y, :z
# Make a new vector with the specified +x+, +y+, and +z+ components.
def initialize(*args)
@x, @y, @z = args
end
# Create a string that displays the +x+, +y+, and +z+ coordinates of the vector.
def inspect
pos = [@x,@y,@z].map { |x| sprintf "%.5g", x }
return "(" + pos.join(",") + ")"
end
# Compare this vector with another vector +v+. Two vectors are the same if
# all three components are the same.
def ==(v)
return (@x == v.x) && (@y == v.y) && (@z == v.z)
end
# Create a new vector that is the sum of this vector and vector +v+.
def +(v)
Vector.new(@x + v.x, @y + v.y, @z + v.z)
end
# Create a new vector that is the difference between this vector and vector +v+.
def -(v)
Vector.new(@x - v.x, @y - v.y, @z - v.z)
end
# Create a new vector that is the product of this vector and scalar +a+.
def *(a)
Vector.new(@x * a, @y * a, @z * a)
end
# Add the components of vector +v+ to this vector.
def add(v)
@x += v.x
@y += v.y
@z += v.z
self
end
# Subtract the components of vector +v+ from this vector.
def sub(v)
@x -= v.x
@y -= v.y
@z -= v.z
self
end
# Multiply each component of this vector by scalar +a+.
def scale(a)
@x *= a
@y *= a
@z *= a
end
# Compute the magnitude of this vector, which is the Euclidean norm
# defined by sqrt(x**2 + y**2 + z**2)
def norm
sqrt(@x*@x + @y*@y + @z*@z)
end
# Compute the angle between this vector and vector +v+. This method is
# only used when drawing 2D vectors, so the z dimension is ignored.
def angle(v)
acos((@x * v.x + @y * v.y) / (norm * v.norm))
end
# Return a vector of three Floats corresponding to the +x+, +y+, and +z+
# components of this vector.
def coords
[@x, @y, @z]
end
# Set the three components of this vector to the values in the array +a+,
# which should be an array of three Floats (it can have more values, but
# only the first three are used).
def coords=(a)
@x = a[0]
@y = a[1]
@z = a[2]
end
end # Vector
=begin rdoc
== Body
A Body object represents the state of a celestial body. A body has mass (a scalar),
position (a vector), and velocity (a vector). A third vector, named force, is used
when calculating forces acting on a body. The size and color attributes are used by
the visualization methods.
=end
class Body
attr_accessor :mass, :position, :velocity, :force
attr_accessor :name, :size, :color, :prevx, :prevy, :graphic
# Create a new Body object. If the argument list is empty, the attributes (mass,
# position, etc) are all set to 0. Otherwise the arguments should be +mass+ (a
# Float), +position+ (a vector), and +velocity+ (another vector). A fourth
# argument is an optional name for the body.
#
# Example:
# >> b = Body.new
# => : 0 kg (0,0,0) (0,0,0)
# >> b = Body.new(1e6, Vector.new(0,0,0), Vector.new(0,0,0), "test")
# => test: 1e+06 kg (0,0,0) (0,0,0)
def initialize(*args)
if args.length > 0
@mass, @position, @velocity, @name = args
else
@mass = 0.0
@position = Vector.new(0.0, 0.0, 0.0)
@velocity = Vector.new(0.0, 0.0, 0.0)
@name = nil
end
@force = Vector.new(0.0, 0.0, 0.0)
end
# Make a copy of this body.
def clone
copy = super
if graphic
copy.position = position.clone
copy.velocity = velocity.clone
copy.force = force.clone
copy.graphic = Canvas::Circle.new(prevx, prevy, size, :fill => color)
end
return copy
end
# Create a string that summarizes the state of this body.
def inspect
name = @name ? @name : ""
return sprintf "%s: %.3g kg %s %s", name, @mass, @position.inspect, @velocity.inspect
end
# Reset the force vector to (0,0,0). Called by SphereLab#step_system at the start of each
# new round of interaction calculations.
def clear_force
@force.x = 0.0
@force.y = 0.0
@force.z = 0.0
end
# Compute the force exerted on this body by body +b+ and update this body's force vector.
def add_force(b)
r = @position - b.position
nr = r.norm ** 3
mr = b.mass / nr
@force.add(r * mr)
end
# Update this body's position by applying the current force vector for +dt+ seconds.
def move(dt)
acc = @force * G * -1.0
@velocity.add( acc * dt )
@position.add( @velocity * dt )
end
# This class method will compute the interaction between bodies b1 and b2 and update
# their force vectors. Since the forces on the bodies are the same, but acting in the
# opposite direction, the force can be calculated just once and then used to update both
# bodies.
def Body.interaction(b1, b2)
r = b1.position - b2.position
a = r.norm ** 3
b1.force.add(r * (b2.mass / a))
b2.force.add(r * (-b1.mass / a))
end
end # Body
=begin rdoc
== Turtle
This class implements a rudimentary "turtle graphics" system. A turtle is an object that
moves around on a canvas, under direction from a user/program that tells it to advance, turn,
etc. A turtle also has a "pen" that can be raised or lowered. When the pen is down, the
turtle draws a line on the canvas as it moves.
A Turtle object is used in the robot explorer experiment, where the object is to get the robot
to move in as smooth a circle as possible about a central point.
=end
class Turtle
attr_accessor :position, :velocity, :reference, :graphic
@@turtleOptions = {
:x => 20,
:y => 100,
:heading => 0,
:speed => 10,
:track => :off,
}
@@north = Vector.new(0, 10, 0)
# Create a new Turtle object. The arguments (all optional) specify the turtle's starting
# position and orientation. The possible options and their default values:
# :x => 20
# :y => 100
# :heading => 0
# :speed => 10
# :track => :off
# Example:
# >> t = Turtle.new
# => #
# >> t = Turtle.new( :x => 100, :y => 100, :speed => 5 )
# => #
def initialize(args = nil)
args = { } if args.nil?
raise "Turtle: initialize: args must be a Hash" unless args.class == Hash
options = @@turtleOptions.merge(args)
alpha = Canvas.radians(options[:heading] + 90.0)
@position = Vector.new(options[:x], options[:y], 0.0)
if options[:speed] > 0
@velocity = Vector.new(-1.0 * options[:speed] * cos(alpha), options[:speed] * sin(alpha), 0.0)
else
raise "Turtle.new: speed must be greater than 0"
end
@graphic = nil
@reference = nil
@tracking = options[:track]
end
# Create a string that summarizes the status of this turtle object.
def inspect
sprintf "#", @position.x, @position.y, heading, speed
end
# Return an array of two numbers representing the +x+ and +y+ coordinates of this object.
def location
return [@position.x, @position.y]
end
# Return the current heading, in compass degrees, of this turtle object. The heading is a number between 0 and 360,
# where 0 is north, 90 is east, etc.
def heading
d = Canvas.degrees( @velocity.angle(@@north) )
return (@velocity.x < 0) ? (360 - d) : d
end
# Return the current speed (in meters per second) of this turtle object.
def speed
@velocity.norm
end
# Reorient the turtle by telling it to turn clockwise by +alpha+ degrees.
def turn(alpha)
theta = -1.0 * Canvas.radians(alpha)
x = @velocity.x * cos(theta) - @velocity.y * sin(theta)
y = @velocity.x * sin(theta) + @velocity.y * cos(theta)
@velocity.x = x
@velocity.y = y
if @graphic
@graphic.rotate(alpha)
end
return alpha
end
# Tell the turtle to move straight ahead from its current position for +dt+ seconds.
def advance(dt)
prevx = @position.x
prevy = @position.y
@position.add( @velocity * dt )
if @graphic
Canvas.move(@graphic, @position.x-prevx, prevy-@position.y, @tracking)
end
return dt
end
# Tell the turtle to rotate until its heading is orthogonal to a reference point.
# See the Lab Manual for details about setting a reference point and how the
# orientation is calculated.
def orient
if @reference.nil?
puts "no reference point"
return nil
end
mx, my = @reference
tx, ty = @position.x, @position.y
mid = Vector.new(tx-mx, ty-my, 0.0)
theta = Canvas.degrees(mid.angle(@velocity))
alpha = 2 * (90.0 - theta)
turn(alpha)
return alpha
end
# Turn tracking :on or :off. When tracking is :on the
# turtle draws a line on the canvas as it moves.
def track(val)
case val
when :off : @tracking = :off
when :on : @tracking = :track
else
puts "tracking is either :on or :off"
return nil
end
return val
end
end # class Turtle
# Initialize the RubyLabs Canvas for an experiment with the robot explorer. The
# canvas will show a map of an area 400 x 400 meters and the robot (represented by
# a Turtle object) on the west edge, facing north. The two options that can be
# passed specify the canvas size and a polygon that determines its shape:
# :canvasSize => 400,
# :polygon => [4,0,0,10,4,8,8,10],
def view_robot(userOptions = {})
options = @@robotOptions.merge(userOptions)
poly = options[:polygon].clone
edge = options[:canvasSize]
(0...poly.length).step(2) do |i|
poly[i] += edge/10
poly[i+1] += edge/2
end
Canvas.init(edge, edge, "SphereLab::Robot")
turtle = Turtle.new( :x => edge/10, :y => edge/2, :heading => 0, :speed => 10 )
turtle.graphic = Canvas::Polygon.new(poly, :outline => 'black', :fill => '#00ff88')
if options[:flag]
turtle.set_flag( *options[:flag] )
end
if options[:track]
turtle.track( options[:track] )
end
class <robot.orient to reorient the robot. The flag will be shown as
# a circle on the canvas at the specified position.
def set_flag(fx, fy)
r = 3.0
Canvas::Circle.new( fx + r/2, fy + r/2, r, :fill => 'darkblue' )
@reference = [ fx, fy ]
end
# Return a reference to the Turtle object that represents the robot explorer
# (created when the user calls view_robot).
# In experiments, users combine a call to this method with a call to a method that
# controls the robot.
#
# Example:
# >> robot
# => #
# >> robot.heading
# => 360.0
# >> robot.turn(30)
# => 30
def robot
if @@drawing.nil?
puts "No robot; call view_robot to initialize"
return nil
else
return @@drawing.turtle
end
end
# The methods that make random bodies need to be fixed -- they need a more formal
# approach to define a system that "keeps together" longer. In the meantime, turn
# off documentation....
def random_vectors(r, i, n) # :nodoc:
theta = (2 * PI / n) * i + (PI * rand / n)
# radius = r + (r/3)*(rand-0.5)
radius = r + r * (rand-0.5)
x = radius * cos(theta)
y = radius * sin(theta)
vtheta = (PI - theta) * -1 + PI * (rand-0.5)
vx = radius/20 * cos(vtheta)
vy = radius/20 * sin(vtheta)
return Vector.new(x, y, 0), Vector.new(vx, vy, 0)
end
def random_bodies(n, big) # :nodoc:
big = 1 if big.nil?
moving = true if moving.nil?
res = []
mm = 1e12 # average mass
mr = 150 # average distance from origin
bigm = (mm * 100) / big
big.times do |i|
r, v = random_vectors(mr/2, i, big)
ms = (1 + (rand/2 - 0.25) )
b = Body.new(bigm*ms, r, v)
b.name = "b#{i}"
b.color = '#0080ff'
b.size = 10*ms
res << b
end
(n-big).times do |i|
r, v = random_vectors(mr, i, n-big)
b = Body.new(mm, r, v)
b.name = "b#{i+big}"
b.color = '#0080ff'
b.size = 5
res << b
end
return res
end
def falling_bodies(n) # :nodoc:
raise "n must be 5 or more" unless n >= 5
a = random_bodies(n-1, n-1)
b = Body.new(1e13, (a[0].position + a[1].position), Vector.new(0,0,0))
# b = Body.new(1e13, (a[0].position + a[1].position)*0.85, Vector.new(0,0,0))
# pos = a[0].position
# (1..(n-2)).each { |i| pos.add( a[i].position ) }
# b = Body.new(1e14, pos * (1.0 / n), Vector.new(0,0,0))
b.name = "falling"
b.size = 5
b.color = 'red'
a.insert(0, b)
return a
end
# Initialize a new n-body system, returning an array of body objects. If the argument
# is a symbol it is the name of a predefined system in the SphereLab data directory,
# otherwise it should be the name of a file in the local directory.
#
# Example:
# >> b = make_system(:melon)
# => [melon: 3 kg (0,6.371e+06,0) (0,0,0), earth: 5.97e+24 kg (0,0,0) (0,0,0)]
# >> b = make_system(:solarsystem)
# => [sun: 1.99e+30 kg (0,0,0) (0,0,0), ... pluto: 1.31e+22 kg (-4.5511e+12,3.1753e+11,1.2822e+12) (636,-5762.1,440.88)]
#--
# When random bodies are working again add this back in to the comment:
# ... or the symbol :random, which...
def make_system(*args)
bodies = []
begin
raise "usage: make_system(id)" unless args.length > 0
if args[0] == :random
raise "usage: make_system(:random, n, m)" unless args.length >= 2 && args[1].class == Fixnum
return random_bodies(args[1], args[2])
elsif args[0] == :falling
raise "usage: make_system(:falling, n)" unless args.length >= 1 && args[1].class == Fixnum
return falling_bodies(args[1])
end
filename = args[0]
if filename.class == Symbol
filename = File.join(@@sphereDirectory, filename.to_s + ".txt")
end
File.open(filename).each do |line|
line.strip!
next if line.length == 0
next if line[0] == ?#
a = line.chomp.split
for i in 1..7
a[i] = a[i].to_f
end
b = Body.new( a[1], Vector.new(a[2],a[3],a[4]), Vector.new(a[5],a[6],a[7]), a[0] )
b.size = a[-2].to_i
b.color = a[-1]
bodies << b
end
if args[0] == :melon
class <> b = make_system(:solarsystem)
# => [sun: 1.99e+30 kg (0,0,0) (0,0,0), ... ]
# >> view_system(b[0..3])
# => true
def view_system(blist, userOptions = {})
Canvas.init(700, 700, "SphereLab::N-Body")
if prev = @@drawing
prev.bodies.each { |x| x.graphic = nil }
end
options = @@viewerOptions.merge(userOptions)
origin = setOrigin(options[:origin])
sf = setScale(blist, options[:origin], options[:scale])
blist.each do |b|
x, y = scale(b.position, origin, sf)
b.graphic = Canvas::Circle.new(x, y, b.size, :fill => b.color)
b.prevx = x
b.prevy = y
end
@@drawing = NBodyView.new(blist, origin, sf, options)
if options.has_key?(:dash)
options[:dashcount] = 0
options[:pendown] = :track
elsif options.has_key?(:pendown)
options[:pendown] = :track
end
return true
end
# Run one step of a simulation of an n-body system where only one body is allowed
# to move and all the others are fixed in place. The first argument is a reference
# to the moving body, the second is any array containing references to the other
# bodies in the system, and the third is the time step size.
def update_one(falling, stationary, time)
if falling.graphic.nil?
puts "display the system with view_system"
return nil
end
stationary.each do |x|
Body.interaction( falling, x )
end
falling.move(time)
if @@drawing.options.has_key?(:dash)
@@drawing.options[:dashcount] = (@@drawing.options[:dashcount] + 1) % @@drawing.options[:dash]
if @@drawing.options[:dashcount] == 0
@@drawing.options[:pendown] = @@drawing.options[:pendown].nil? ? :track : nil
end
end
newx, newy = scale(falling.position, @@drawing.origin, @@drawing.scale)
Canvas.move(falling.graphic, newx-falling.prevx, newy-falling.prevy, @@drawing.options[:pendown])
falling.prevx = newx
falling.prevy = newy
falling.clear_force
return true
end
# Run one time step of a full n-body simulation. Compute the pairwise interactions of all
# bodies in the system, update their force vectors, and then move them an amount determined
# by the time step size +dt+.
#--
# :begin :step_system
def step_system(bodies, dt)
nb = bodies.length
for i in 0..(nb-1) # compute all pairwise interactions
for j in (i+1)..(nb-1)
Body.interaction( bodies[i], bodies[j] )
end
end
bodies.each do |b|
b.move(dt) # apply the accumulated forces
b.clear_force # reset force to 0 for next round
end
end
# :end :step_system
# This method will call step_system to simulate the motion of a set of bodies for the
# specified amount of time +dt+ and then update their positions on the canvas.
def update_system(bodies, dt)
return false unless @@drawing
step_system(bodies, dt)
if @@drawing.options.has_key?(:dash)
@@drawing.options[:dashcount] = (@@drawing.options[:dashcount] + 1) % @@drawing.options[:dash]
if @@drawing.options[:dashcount] == 0
@@drawing.options[:pendown] = @@drawing.options[:pendown].nil? ? :track : nil
end
end
bodies.each do |b|
next unless b.graphic
newx, newy = scale(b.position, @@drawing.origin, @@drawing.scale)
Canvas.move(b.graphic, newx-b.prevx, newy-b.prevy, @@drawing.options[:pendown])
b.prevx = newx
b.prevy = newy
end
return true
end
# Map a simulation's (x,y) coordinates to screen coordinates using origin
# and scale factor
def scale(vec, origin, sf) # :nodoc:
loc = vec.clone
loc.scale(sf)
loc.add(origin)
return loc.x, loc.y
end
# Make a vector that defines the location of the origin (in pixels).
def setOrigin(type) # :nodoc:
case type
when :center
Vector.new( Canvas.width/2, Canvas.height/2, 0)
else
Vector.new(0,0,0)
end
end
# Set the scale factor. Use the parameter passed by the user, or find the
# largest coordinate in a list of bodies. Add 20% for a margin
def setScale(blist, origin, scale) # :nodoc:
if scale == nil
dmax = 0.0
blist.each do |b|
b.position.coords.each { |val| dmax = val.abs if val.abs > dmax }
end
else
dmax = scale
end
sf = (origin == :center) ? (Canvas.width / 2.0) : Canvas.width
return (sf / dmax) * 0.8
end
# Initialize the RubyLabs Canvas to show a drawing of a "2-body system" with
# a small circle to represent a watermelon and a much larger partial circle for the
# earth. The two Body objects representing the melon and earth should be passed
# in the array +blist+. Additonal optional arguments are viewing options, which have
# the following defaults:
# :canvasSize => 400
# :mxmin => 100 maximum melon x position
# :mymin => 50, maximum melon y position
# :hmax => 100, maximum melon height
# :dash => 1, size of dashes drawn as melon falls
#
# Example:
# >> b = make_system(:melon)
# => [melon: 3 kg (0,6.371e+06,0) (0,0,0), earth: 5.97e+24 kg (0,0,0) (0,0,0)]
# >> view_melon(b)
# => true
def view_melon(blist, userOptions = {})
options = @@droppingOptions.merge(userOptions)
options[:dashcount] = 0
options[:pendown] = :track
edge = options[:canvasSize]
mxmin = options[:mxmin]
mymin = options[:mymin]
hmax = options[:hmax]
Canvas.init(edge, edge, "SphereLab::Melon")
earth = Canvas::Circle.new(200, 2150, 1800, :fill => blist[1].color)
blist[1].graphic = earth
mymax = earth.coords[1]
melon = Canvas::Circle.new(mxmin, mymax, 5, :fill => blist[0].color)
blist[0].graphic = melon
scale = (mymax-mymin) / hmax.to_f
@@drawing = MelonView.new(blist, scale, blist[0].position.y, melon.coords, options)
return true
end
# Position the melon (the first body in the array +blist+) at a specified height
# above the earth.
#
# Example:
# >> position_melon(b, 50)
# => 50
#--
# If no drawing, save the current position in prevy, but if drawing, get the earth's
# surface from the drawing (this allows students to move the melon up or down in the
# middle of an experiment)
def position_melon(blist, height)
melon = blist[0]
if @@drawing && blist[0].graphic
if height < 0 || height > @@drawing.options[:hmax]
puts "Height must be between 0 and #{@@drawing.options[:hmax]} meters"
return false
end
melon.prevy = @@drawing.ground
melon.position.y = @@drawing.ground + height
a = @@drawing.startloc.clone
a[1] -= height * @@drawing.scale
a[3] -= height * @@drawing.scale
melon.graphic.coords = a
else
melon.prevy = melon.position.y
melon.position.y += height
end
melon.velocity.y = 0.0
return height
end
# Execute one time step of the two-body simulation involving the two objects in
# +blist+. Similar to the general purpose method update_system, except the
# drawing position of the earth is not updated.
def update_melon(blist, dt)
melon = blist[0]
mx = melon.position.x
my = melon.position.y
return "splat" if melon.prevy.nil? || my < melon.prevy
step_system(blist, dt)
if @@drawing && blist[0].graphic
if @@drawing.options[:dash] > 0
@@drawing.options[:dashcount] = (@@drawing.options[:dashcount] + 1) % @@drawing.options[:dash]
if @@drawing.options[:dashcount] == 0
@@drawing.options[:pendown] = @@drawing.options[:pendown].nil? ? :track : nil
end
end
dx = (melon.position.x - mx) * @@drawing.scale
dy = (my - melon.position.y) * @@drawing.scale
Canvas.move(melon.graphic, dx, dy, @@drawing.options[:pendown])
end
return blist[0].height
end
# Repeatedly call the update_melon method until the +y+ position of the melon
# is less than or equal to the +y+ position of the surface of the earth. The
# two objects representing the melon and earth are passed in the array +blist+,
# and +dt+ is the size of the time step to use in calls to update_melon.
# The return value is the amount of simulated time it takes the melon to hit
# the earth; it will be the product of +dt+ and the number of time steps (number of
# calls to update_melon).
#
# Example -- to run an experiment that drops the melon from 50 meters, using a
# time step size of .01 seconds:
# >> position_melon(b, 50)
# => 50
# >> drop_melon(b, 0.01)
# => 3.19
#--
# :begin :drop_melon
def drop_melon(blist, dt)
count = 0
loop do
res = update_melon(blist, dt)
break if res == "splat"
count += 1
end
return count * dt
end
# :end :drop_melon
# Compute the distance traveled by an object moving at velocity +r+ for
# an amount of time +t+.
#--
# :begin :dist
def dist(r, t)
return r * t
end
# :end :dist
# Compute the distance an object will fall when it is initially stationary
# and then falls for an amount of time +dt+, accelerating
# according to the force of the Earth's gravity.
#--
# :begin :falling
def falling(t)
return 0.5 * 9.8 * t**2
end
# :end :falling
end # SphereLab
end # RubyLabs