Page MenuHomec4science

suspendedobject.py
No OneTemporary

File Metadata

Created
Sun, Apr 28, 00:28

suspendedobject.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 IPython.display import display, Math, Markdown, Latex, HTML
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(hide_banner=True)
class SuspendedObjectLab:
"""
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, mass_object = 3, distance = 5, height = 1.5, 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 = mass_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
# Parameters for drawing forces
self.gravity = 9.81
self.force_scaling = .01
self.forces_nb = 4
# 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)
# parameter to draw the angle
self.radius=0.3
def launch(self):
###--- Elements of the ihm:
# 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 = radians_to_degrees(alpha)
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 = "Distance (m) "
#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=self.height, dimension='width', line_color='gray', line_dash='dashed', line_width=1))
# Draw the poles
fig_object.line([self.x_origin, self.x_origin], [self.y_origin, self.y_origin+self.height], color="black", line_width=8, line_cap="round")
fig_object.line([self.x_origin+self.distance, self.x_origin+self.distance], [self.y_origin, self.y_origin+self.height], color="black", line_width=8, line_cap="round")
# Draw the ground
fig_object.add_layout(Span(location=self.x_origin, dimension='width', line_color='black', line_width=1))
fig_object.hbar(y=self.y_origin-ymargin, height=ymargin*2, left=self.x_origin-xmargin, right=self.x_origin+self.distance+xmargin, color="white", line_color="white", hatch_pattern="/", 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
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
# 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")
# Adding a legend to explain the acronyms of forces
# legend_source = ColumnDataSource(data=dict(
# x=[0.15, 0.15, 0.15, 0.15],
# y=[0.05, 0.15, 0.25, 0.35],
# text=["Tr = Resulting Tension", "T = Tension", "F = Weight", "Forces:"],
# color=["gray", "red", "blue", "black"]
# ))
# legend_labels = LabelSet(source=legend_source, x='x', y='y',
# text='text', text_color='color',
# render_mode='canvas')
# fig_object.add_layout(legend_labels)
###--- 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(radians_to_degrees(a))
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=self.height, 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,angle_list[0]+1), 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]))
display(VBox([self.m_counterweight_input]))
# 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 = radians_to_degrees(alpha)
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()
def visualize_counterweight(self, m_counterweight = 0):
### first let's validate the counterweight
# it cannot be negative
if m_counterweight < 0:
print("\033[1m\x1b[91m The mass of the counterweight cannot be negative. \x1b[0m\033[0m")
return
# update attribute with new value
self.m_counterweight = m_counterweight
# compute the angle given by this counterweight
alpha = self.get_angle(self.m_counterweight)
alpha_degrees = radians_to_degrees(alpha)
# visualize the situation
self.visualize_angle(alpha_degrees)
def visualize_angle(self, angle_degrees):
### first let's validate the angle
# it cannot be null (i.e. cable horizontal) or negative
if angle_degrees <= 0:
print("\033[1m\x1b[91m The angle cannot be null or negative. \x1b[0m\033[0m")
return
# it cannot be more than the default angle given the parameters of the situation
alpha_default = np.arctan(self.height / (self.distance / 2))
alpha_default_degrees = radians_to_degrees(alpha_default)
if angle_degrees > alpha_default_degrees:
print(f"\033[1m\x1b[91m The angle cannot be greater than {alpha_default_degrees:.2f} degrees given the parameters of this situation (poles of {self.height} meters, distant by {self.distance} meters). \x1b[0m\033[0m")
angle_degrees = alpha_default_degrees
# compute variables dependent with the angle selected by the user
alpha_degrees = angle_degrees
alpha = degrees_to_radians(alpha_degrees)
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=800, plot_height=400, #sizing_mode='scale_both',
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)'
fig_object.xaxis.axis_label = "Distance (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
# Draw the horizon line
fig_object.add_layout(Span(location=self.height, dimension='width', line_color='gray', line_dash='dashed', line_width=1))
# Draw the poles
fig_object.line([self.x_origin, self.x_origin], [self.y_origin, self.y_origin+self.height], color="black", line_width=8, line_cap="round")
fig_object.line([self.x_origin+self.distance, self.x_origin+self.distance], [self.y_origin, self.y_origin+self.height], color="black", line_width=8, line_cap="round")
# Draw the ground
fig_object.add_layout(Span(location=self.x_origin, dimension='width', line_color='black', line_width=1))
fig_object.hbar(y=self.y_origin-ymargin, height=ymargin*2, left=self.x_origin-xmargin, right=self.x_origin+self.distance+xmargin, color="white", line_color="white", hatch_pattern="/", 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=-35))
# --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
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
# 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, 15, 8, -25],
y_offset=[-64, -16, 45, -16]
))
# Draw the arrows
### Bokeh issue here: with a datasource, it is not possible to specify the color of the openhead so it remains black
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))
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")
###--- Display the whole interface
show(row(children=[fig_object]), notebook_handle=True) #, sizing_mode="scale_both"
# 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 delta height higher 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