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