diff --git a/lib/suspendedobject.py b/lib/suspendedobject.py index be60226..7380fc8 100644 --- a/lib/suspendedobject.py +++ b/lib/suspendedobject.py @@ -1,559 +1,559 @@ 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 import matplotlib.pyplot as plt plt.style.use('seaborn-whitegrid') # global style for plotting 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(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, m_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 = 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 # 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") ###--- 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 = ''' ''' 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=-20)) + 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, 25, 8, -35], - y_offset=[-45, 6, 45, 6] + 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