Page MenuHomec4science

suspendedobjects.py
No OneTemporary

File Metadata

Created
Wed, Nov 27, 17:28

suspendedobjects.py

import numpy as np
from operator import add
from ipywidgets import interact, interactive, fixed, interact_manual
from ipywidgets import HBox, VBox, Label, Layout
import ipywidgets as widgets
from IPython.display import IFrame
from IPython.display import set_matplotlib_formats, display, Math, Markdown, Latex, HTML
set_matplotlib_formats('svg')
from bokeh.io import push_notebook, show, output_notebook, curdoc
from bokeh.plotting import figure
from bokeh.models import Legend, ColumnDataSource, Slider, Span, LegendItem
from bokeh.models import Arrow, OpenHead, NormalHead, VeeHead, LabelSet
from bokeh.models.glyphs import Wedge, Bezier
from bokeh.layouts import gridplot, row, column
output_notebook()
class SuspendedObjectsLab:
"""
This class embeds all the necessary code to create a virtual lab to study the static equilibrium of an object suspended on a clothesline with a counterweight.
"""
def __init__(self, m_object = 3, distance = 5, height = 1, x_origin = 0, y_origin = 0):
'''
Initiates and displays the virtual lab on suspended objects.
:m_object: mass of the suspended object
:distance: horizontal distance between the two poles
:height: height of the poles (same height for both)
:x_origin: x coordinate of the bottom of the left pole (origin of the coordinate system)
:y_origin: y coordinate of the bottom of the left pole (origin of the coordinate system)
'''
###--- Static parameters of the situation
self.m_object = m_object # mass of the wet object, in kg
self.distance = distance # distance between the poles, in m
self.height = height # height of the poles, in m
self.x_origin = x_origin # x coordinate of point of origin of the figure = x position of the left pole, in m
self.y_origin = y_origin # y coordinate of point of origin of the figure = y position of the lower point (ground), in m
###--- Elements of the ihm:
# parameters for sliders
self.m_counterweight_min = 0.0
self.m_counterweight_max = 100.0
self.m_counterweight = self.m_counterweight_min # initial mass of the counterweight (0 by default, no counterweight at the beginning)
# IHM input elements
self.m_counterweight_label = Label('Mass of the counterweight ($kg$):', layout=Layout(margin='0px 5px 0px 0px'))
self.m_counterweight_widget = widgets.FloatSlider(min=self.m_counterweight_min,max=self.m_counterweight_max,step=0.5,value=self.m_counterweight, layout=Layout(margin='0px'))
self.m_counterweight_note = Label('[Note: once you have clicked on the slider (the circle becomes blue), you can use the arrows from your keyboard to make it move.]', layout=Layout(margin='0px 0px 15px 0px'))
self.m_counterweight_input = VBox([HBox([self.m_counterweight_label, self.m_counterweight_widget], layout=Layout(margin='0px')), self.m_counterweight_note])
# Linking widgets to handlers
self.m_counterweight_widget.observe(self.m_counterweight_event_handler, names='value')
# IHM output element for moodle quiz
self.quiz_output = widgets.Output()
###--- Compute variables dependent with the counterweight selected by the user
alpha = self.get_angle(self.m_counterweight)
alpha_degrees = alpha*180/np.pi
alpha_text = '⍺ = {:.2f} °'.format(alpha_degrees)
coord_object = self.get_object_coords(alpha)
height_text = 'h = {:.2f} m'.format(coord_object[1])
###--- Create the figure ---###
# LIMITATIONS of Bokeh (BokehJS 1.4.0)
# - labels: impossible to use LaTeX formatting in labels
# - arc: impossible to take into account figure ratio when dynamic rescaling is activated
# - forces/vectors: impossible to adjust the line_color and line_dash of OpenHead according to datasource
###--- First display the clothesline
# Fix graph to problem boundaries
ymargin = .05
xmargin = .2
fig_object = figure(title='Suspended object ({} kg)'.format(self.m_object), #plot_width=600, plot_height=400, sizing_mode='stretch_height',
y_range=(self.y_origin-ymargin,self.y_origin+self.height+ymargin), x_range=(self.x_origin-xmargin,self.x_origin+self.distance+xmargin),
background_fill_color='#ffffff', toolbar_location=None)
fig_object.title.align = "center"
fig_object.yaxis.axis_label = 'Height (m)'
# Customize graph style so that it doesn't look too much like a graph
fig_object.ygrid.visible = False
fig_object.xgrid.visible = False
fig_object.outline_line_color = None
# trick to keep the three graphs aligned, while having auto scaling
fig_object.xaxis.axis_label = " "
fig_object.xaxis.axis_line_color = "white"
fig_object.xaxis.axis_label_text_color = "white"
fig_object.xaxis.major_label_text_color = "white"
fig_object.xaxis.major_tick_line_color = "white"
fig_object.xaxis.minor_tick_line_color = "white"
# Draw the horizon line
fig_object.add_layout(Span(location=height, dimension='width', line_color='gray', line_dash='dashed', line_width=1))
# Draw the poles
fig_object.line([x_origin, x_origin], [y_origin, y_origin+height], color="black", line_width=8, line_cap="round")
fig_object.line([x_origin+distance, x_origin+distance], [y_origin, y_origin+height], color="black", line_width=8, line_cap="round")
# Draw the ground
fig_object.add_layout(Span(location=x_origin, dimension='width', line_color='black', line_width=1))
fig_object.hbar(y=y_origin-ymargin, height=ymargin*2, left=x_origin-xmargin, right=x_origin+distance+xmargin, color="white", line_color="white",
hatch_pattern="right_diagonal_line", hatch_color="gray")
# --DYN-- Draw the point at which the object is suspended (this data source also used for the other graphs)
self.object_source = ColumnDataSource(data=dict(
x=[coord_object[0]],
y=[coord_object[1]],
m_counterweight=[self.m_counterweight],
alpha_degrees=[alpha_degrees],
height_text=[height_text],
alpha_text=[alpha_text]
))
fig_object.circle(source=self.object_source, x='x', y='y', size=8, fill_color="black", line_color='black', line_width=2)
fig_object.add_layout(LabelSet(source=self.object_source, x='x', y='y', text='height_text', level='glyph', x_offset=8, y_offset=-20))
# --DYN-- Draw the hanging cable
self.cable_source = ColumnDataSource(data=dict(
x=[self.x_origin, coord_object[0], self.x_origin+self.distance],
y=[self.y_origin+self.height, coord_object[1], self.y_origin+self.height]
))
fig_object.line(source=self.cable_source, x='x', y='y', color="black", line_width=2, line_cap="round")
# --DYN-- Draw the angle between the hanging cable and horizonline
# Trick here: we use a straight line because the Arc glyph doesn't support dynamic figure ratio
self.radius=0.3
ratio=1.5
x0=self.x_origin+ratio*self.radius
y0=self.y_origin+self.height
x1=(self.x_origin+self.radius*np.cos(alpha))
y1=(self.y_origin+self.height-self.radius*np.sin(alpha))
self.alpha_arc = fig_object.line([x0, x1], [y0, y1], color="gray", line_width=1, line_dash=[2,2])
fig_object.add_layout(LabelSet(source=self.object_source, x=self.x_origin, y=self.y_origin+self.height, text='alpha_text', level='glyph', x_offset=50, y_offset=-20))
# --DYN-- Draw the force vectors
# Parameters for drawing forces
self.gravity = 9.81
self.force_scaling = .01
self.forces_nb = 4
# Weight
Fy = self.m_object*self.gravity*self.force_scaling
# Tension
Tx = ((self.m_object*self.gravity) / (2*np.tan(alpha)))*self.force_scaling
Ty = .5*self.m_object*self.gravity*self.force_scaling
self.forces_x_start = [coord_object[0]]*self.forces_nb
self.forces_y_start = [coord_object[1]]*self.forces_nb
self.forces_x_mag = [0, Tx, 0, -Tx]
self.forces_y_mag = [-Fy, Ty, Fy, Ty]
self.forces_source = ColumnDataSource(data=dict(
x_start=self.forces_x_start,
y_start=self.forces_y_start,
x_end=list(map(add, self.forces_x_start, self.forces_x_mag)),
y_end=list(map(add, self.forces_y_start, self.forces_y_mag)),
name=["F", "T", "Tr", "T"],
color=["blue", "red", "gray", "red"],
dash=["solid", "solid", [2,2], "solid"],
x_offset=[8, 25, 8, -35],
y_offset=[-45, 6, 45, 6]
))
# Draw the arrows
forces_arrows = Arrow(source=self.forces_source, x_start='x_start', y_start='y_start', x_end='x_end', y_end='y_end',
line_color='color', line_width=2, end=OpenHead(line_width=2, size=12, line_color='black'))
fig_object.add_layout(forces_arrows)
# Add the labels
forces_labels = LabelSet(source=self.forces_source, x='x_start', y='y_start', text='name', text_color='color', level='glyph',
x_offset='x_offset', y_offset='y_offset', render_mode='canvas')
fig_object.add_layout(forces_labels)
# --DYN-- Draw the tension projection lines
self.proj_source = ColumnDataSource(data=dict(
x=self.forces_source.data["x_end"][1:4],
y=self.forces_source.data["y_end"][1:4]
))
fig_object.line(source=self.proj_source, x='x', y='y', color="gray", line_width=1, line_dash="dashed")
###--- Then display the angle and the height as functions from the mass of the counterweight
# Create all possible values of the mass of the counterweight
m_cw_list = np.linspace(self.m_counterweight_min, self.m_counterweight_max, 100)
# Compute the angle (in degrees) and height for all these values
angle_list = []
height_list = []
for m in m_cw_list:
a = self.get_angle(m)
angle_list.append(a*180/np.pi)
c = self.get_object_coords(a)
height_list.append(c[1])
### Create the graph for height
fig_height = figure(title='Height (m)', #plot_height=400, plot_width=200,
y_range=(-ymargin,self.height+ymargin), x_range=(0,102),
background_fill_color='#ffffff', toolbar_location=None)
fig_height.title.align = "center"
fig_height.xaxis.axis_label = 'Mass of the counterweight (kg)'
fig_height.grid.grid_line_color = 'lightgray'
fig_height.grid.minor_grid_line_color = 'gainsboro'
# Draw the curve of the function
fig_height.line(m_cw_list, height_list, color="green", line_width=1)
# Draw the horizon line
fig_height.add_layout(Span(location=1, dimension='width', line_color='gray', line_dash='dashed', line_width=1))
# --DYN-- Add the current height from the counterweight selected by the user
fig_height.circle(source=self.object_source, x='m_counterweight', y='y', size=8, fill_color="black", line_color='black', line_width=2, legend_field="height_text")
fig_height.legend.location = "bottom_right"
fig_height.legend.label_text_font_size = '12pt'
### Create the graph for angle
fig_alpha = figure(title='Angle ⍺ (°)', #plot_height=400, plot_width=400,
y_range=(-1,23), x_range=(0,102),
background_fill_color='#ffffff', toolbar_location=None)
fig_alpha.title.align = "center"
fig_alpha.xaxis.axis_label = 'Mass of the counterweight (kg)'
fig_alpha.grid.grid_line_color = 'lightgray'
fig_alpha.grid.minor_grid_line_color = 'gainsboro'
# Draw the curve of the function
c = fig_alpha.line(m_cw_list, angle_list, color="green", line_width=1)
# Draw the horizon line
fig_alpha.add_layout(Span(location=0, dimension='width', line_color='gray', line_dash='dashed', line_width=1))
# --DYN-- Add the current angle from the counterweight selected by the user
fig_alpha.circle(source=self.object_source, x='m_counterweight', y='alpha_degrees', size=8, fill_color="black", line_color='black', line_width=2, legend_field="alpha_text")
fig_alpha.legend.location = "top_right"
fig_alpha.legend.label_text_font_size = '12pt'
###--- Add a quiz from Moodle
iframe_quiz = '''
<iframe src="https://moodle.epfl.ch/mod/hvp/embed.php?id=1041582" width="800" height="400" frameborder="0" allowfullscreen="allowfullscreen"></iframe>
<script src="https://moodle.epfl.ch/mod/hvp/library/js/h5p-resizer.js" charset="UTF-8"></script>
'''
widget_quiz = widgets.HTML(value=iframe_quiz)
###--- Display the whole interface
show(row(column(children=[fig_object], sizing_mode='stretch_height'), fig_alpha, fig_height, sizing_mode='scale_both'), notebook_handle=True)
display(VBox([self.m_counterweight_input, widget_quiz]))
# Event handlers
def m_counterweight_event_handler(self, change):
# get new value of counterweight mass
self.m_counterweight = change.new
# compute all problem values
alpha = self.get_angle(self.m_counterweight)
alpha_degrees = alpha*180/np.pi
alpha_text = '⍺ = {:.2f} °'.format(alpha_degrees)
coord_object = self.get_object_coords(alpha)
height_text = 'h = {:.2f} m'.format(coord_object[1])
self.forces_y_start = [coord_object[1]]*self.forces_nb
Tx = ((self.m_object*self.gravity) / (2*np.tan(alpha)))*self.force_scaling
self.forces_x_mag = [0, Tx, 0, -Tx]
# update the object representation on all graphs (coordinates+labels)
self.object_source.data = dict(
x=[coord_object[0]],
y=[coord_object[1]],
m_counterweight=[self.m_counterweight],
alpha_degrees=[alpha_degrees],
height_text=[height_text],
alpha_text=[alpha_text]
)
# update line representing the angle alpha
self.alpha_arc.data_source.patch({
'x' : [(1, self.x_origin+self.radius*np.cos(alpha))],
'y' : [(1, self.y_origin+self.height-self.radius*np.sin(alpha))]
})
# update the point where the object is attached on cable
self.cable_source.patch({
'x' : [(1, coord_object[0])],
'y' : [(1, coord_object[1])]
})
# update point of start for all forces, update Tx and Ty for T
self.forces_source.patch({
'y_start' : [(slice(self.forces_nb), self.forces_y_start)],
'x_end' : [(slice(self.forces_nb), list(map(add, self.forces_x_start, self.forces_x_mag)))],
'y_end' : [(slice(self.forces_nb), list(map(add, self.forces_y_start, self.forces_y_mag)))],
})
# update the tension projection lines
self.proj_source.patch({
'x' : [(slice(3), self.forces_source.data["x_end"][1:4])],
'y' : [(slice(3), self.forces_source.data["y_end"][1:4])]
})
push_notebook()
# Utility functions
def get_angle(self, m_counterweight):
"""
Computes the angle that the cable makes with the horizon depending on the counterweight chosen:
- if the counterweight is sufficient: angle = arcsin(1/2 * m_object / m_counterweight)
- else (object on the ground): alpha = arctan(height / (distance / 2))
:m_counterweight: mass of the chosen counterweight
:returns: angle that the cable makes with the horizon (in rad)
"""
# Default alpha value i.e. object is on the ground
alpha_default = np.arctan(self.height / (self.distance / 2))
alpha = alpha_default
# Let's check that there is actually a counterweight
if m_counterweight > 0:
# Then we compute the ratio of masses
ratio = 0.5 * self.m_object / m_counterweight
# Check that the ratio of masses is in the domain of validity of arcsin ([-1;1])
if abs(ratio) < 1:
alpha = np.arcsin(ratio)
return min(alpha_default, alpha)
def get_object_coords(self, angle):
"""
Computes the position of the object on the cable taking into account the angle determined by the counterweight and the dimensions of the hanging system.
By default:
- the object is supposed to be suspended exactly in the middle of the cable
- the object is considered on the ground for all values of the angle which give a height smaller than the height of the poles
:angle: angle that the cable makes with the horizon
:returns: coordinates of the point at which the object are hanged
"""
# the jean is midway between the poles
x_object = self.x_origin + 0.5 * self.distance
# default y value: the jean is on the ground
y_object = self.y_origin
# we check that the angle is comprised between horizontal (greater than 0) and vertical (smaller than pi/2)
if angle > 0 and angle < (np.pi / 2):
# we compute the delta between the horizon and the point given by the angle
delta = (0.5 * self.distance * np.tan(angle))
# we check that the delta is smaller than the height of the poles (otherwise it just means the jean is on the ground)
if delta <= self.height:
y_object = self.y_origin + self.height - delta
return [x_object, y_object]
def degrees_to_radians(angle_degrees):
return angle_degrees * np.pi / 180
def radians_to_degrees(angle_radians):
return angle_radians * 180 / np.pi
# EOF

Event Timeline