diff --git a/lib/suspendedobject.py b/lib/suspendedobject.py new file mode 100644 index 0000000..be60226 --- /dev/null +++ b/lib/suspendedobject.py @@ -0,0 +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)) + + # --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 + ### 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