diff --git a/README.md b/README.md index f6aa84c..40d1cc2 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,41 @@ # Workshop "Teaching Sciences and Engineering with Jupyter Notebooks" 2023 -This repository contains the notebooks for the EPFL workshop "Teaching Sciences and Engineering with Jupyter Notebooks" 2023-2024. +This repository contains the notebooks for the EPFL workshop "Teaching Sciences and Engineering with Jupyter Notebooks". +Author: [Cécile Hardebolle](https://people.epfl.ch/cecile.hardebolle?lang=en), Center for Digital Education (CEDE), EPFL + +For any enquiries relating to this template or this repository, please contact: [cecile.hardebolle@epfl.ch](mailto:cecile.hardebolle@epfl.ch) or [noto-support@groupes.epfl.ch](mailto:noto-support@groupes.epfl.ch) + +This repository is available at: https://c4science.ch/source/notebooks-workshop/ + +# Copyright and License + +## Content material Except where otherwise noted, the contents of this repository are available under a [CC BY-SA 4.0](http://creativecommons.org/licenses/by-sa/4.0/) license. -For any enquiries relating to this template or this repository, please contact: [noto-support@groupes.epfl.ch](mailto:noto-support@groupes.epfl.ch) +You are free: +* to Share --- copy and redistribute the material in any medium or format +* to Remix --- remix, transform, and build upon the material for any purpose, even commercially. -This repository is available at: https://c4science.ch/source/notebooks-workshop/ +Under the following conditions: +* Attribution --- You must give appropriate credit, provide a link to the license, and indicate if changes were made. You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use. Where practical, you must also include a hyperlink to https://c4science.ch/source/notebooks-workshop/. +* ShareAlike --- If you remix, transform, or build upon the material, you must distribute your contributions under the same license as the original. + +No additional restrictions --- You may not apply legal terms or technological measures that legally restrict others from doing anything the license permits. + +For the full legal text of this license, please see: http://creativecommons.org/licenses/by-sa/4.0/legalcode + + +## Code + +Except where otherwise noted, all code is made available under the OSI-approved BSD-3-Clause license (https://opensource.org/licenses/BSD-3-Clause): + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/lib/interactivevisualization.py b/lib/interactivevisualization.py index 3097178..28a836a 100644 --- a/lib/interactivevisualization.py +++ b/lib/interactivevisualization.py @@ -1,52 +1,57 @@ +### +### Except where otherwise noted, all code is made available under the OSI-approved BSD-3-Clause license (https://opensource.org/licenses/BSD-3-Clause): +### Author: Cécile Hardebolle +### + import matplotlib.pyplot as plt import ipywidgets as widgets # Enable interactive backend for matplotlib from IPython import get_ipython get_ipython().run_line_magic('matplotlib', 'widget') def displayInteractiveHouse(): # We will plot a rectangle to model a house house_x = [2, 2, 4, 4] house_y = [0, 2, 2, 0] # We will plot a triangle to model the roof roof_x = [2, 3, 4] roof_y = [2, 5, 2] # Creation of the figure fig = plt.figure(num='Interactive figure', figsize=(6,4)) # Creation of one subplot/axe - it will take position index number 1 in a grid of 1 row and 1 column, as described by (nrows, ncols, index) ax = fig.add_subplot(1,1,1) # Plot the house ax.plot(house_x, house_y) # Plot the roof, and get the resulting line, on which we will add interactivity later - NOTICE the syntax with the comma "roof_line, =" roof_line, = ax.plot(roof_x, roof_y) # We create a slider with values ranging from 2 to 10 in steps of .5, by default on value 5 roof_widget = widgets.FloatSlider(min=2, max=10, step=0.5, value=5, description='Roof height:') # This function will be called when the slider is moved def roof_event_handler(change): # It allows us to retrieve the new value of the slider newposition = change.new # Then we can change the points of the door line roof_line.set_ydata([2, newposition, 2]) # Finally we tell the figure to draw the changed parts fig.canvas.draw_idle() # Finally we link the widget to the callback function roof_widget.observe(roof_event_handler, names='value') # The figure is automatically displayed since matplotlib is in interactive mode (if we display it explicitely, it will show up twice!) # We only need to display the widget display(roof_widget) diff --git a/lib/suspendedobject.py b/lib/suspendedobject.py index f8ea5ed..262d829 100644 --- a/lib/suspendedobject.py +++ b/lib/suspendedobject.py @@ -1,461 +1,466 @@ +### +### Except where otherwise noted, all code is made available under the OSI-approved BSD-3-Clause license (https://opensource.org/licenses/BSD-3-Clause): +### Author: Cécile Hardebolle +### + 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.alpha_slider_min = 0.5 self.alpha_slider_max = 30 self.alpha_degrees = 20 # initial angle # parameter to draw the angle self.radius=0.3 def launch(self): ###--- Elements of the ihm: # IHM input elements self.alpha_slider_label = Label('Angle α (°):', layout=Layout(margin='0px 5px 0px 0px')) self.alpha_slider_widget = widgets.FloatSlider(min=self.alpha_slider_min,max=self.alpha_slider_max,step=0.5,value=self.alpha_degrees, layout=Layout(margin='0px')) self.alpha_slider_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.alpha_slider_input = VBox([HBox([self.alpha_slider_label, self.alpha_slider_widget], layout=Layout(margin='0px')), self.alpha_slider_note]) # Linking widgets to handlers self.alpha_slider_widget.observe(self.alpha_slider_event_handler, names='value') ###--- Compute variables dependent with alpha alpha = degrees_to_radians(self.alpha_degrees) alpha_text = '⍺ = {:.2f} °'.format(self.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, #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 # Indicate the horizontal scale fig_object.xaxis.axis_label = "Distance (m) " # 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]], alpha_degrees=[self.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") ###--- Display the whole interface show(row(children=[fig_object]), notebook_handle=True) display(VBox([self.alpha_slider_input])) # Event handlers def alpha_slider_event_handler(self, change): # get new value of counterweight mass self.alpha_degrees = change.new # compute the variables depending on alpha alpha = degrees_to_radians(self.alpha_degrees) alpha_text = '⍺ = {:.2f} °'.format(self.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]], alpha_degrees=[self.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_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]], 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, in radians :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 diff --git a/lib/suspendedobjectinteractive.py b/lib/suspendedobjectinteractive.py index 8f040b4..d123c8b 100644 --- a/lib/suspendedobjectinteractive.py +++ b/lib/suspendedobjectinteractive.py @@ -1,342 +1,347 @@ +### +### Except where otherwise noted, all code is made available under the OSI-approved BSD-3-Clause license (https://opensource.org/licenses/BSD-3-Clause): +### Author: Cécile Hardebolle +### + # Enable interactive backend for matplotlib from IPython import get_ipython get_ipython().run_line_magic('matplotlib', 'widget') import numpy as np 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') import matplotlib.pyplot as plt import matplotlib.patches as pat plt.style.use('seaborn-whitegrid') # global style for plotting 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 = 2, 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 ###--- Then we define the 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='15px 5px 15px 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='15px 0px')) self.m_counterweight_input = HBox([self.m_counterweight_label, self.m_counterweight_widget]) # IHM output elements self.quiz_output = widgets.Output() # Linking widgets to handlers self.m_counterweight_widget.observe(self.m_counterweight_event_handler, names='value') ###--- 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 = r'$\alpha$ = {:.2f} $^\circ$'.format(alpha_degrees) coord_object = self.get_object_coords(alpha) height_text = r'h = {:.2f} $m$'.format(coord_object[1]) ###--- Create the figure #plt.ioff() # deactivate interactive mode until we actually decide to show it #plt.clf() # clear any previously drawn figure # Create the figure and subplots in it self.fig = plt.figure(num='Suspended Object Lab', constrained_layout=False, figsize=(10,4)) # hack for interactive backend: num is the title which appears above the canvas gs = self.fig.add_gridspec(ncols=7, nrows=1, wspace=0.5, hspace=0, right=0.95, top=0.9, left=0.05, bottom=0.1) ax1 = self.fig.add_subplot(gs[0, :3]) ax2 = self.fig.add_subplot(gs[0, 3:5], sharey = ax1) ax3 = self.fig.add_subplot(gs[0, 5:7], sharex = ax2) # Deactivate toolbar (hack for interactive backend) # NOT WORKING self.fig.canvas.toolbar_visible = False # Deactivate coordinate formatter (hack for interactive backend) ax1.format_coord = lambda x, y: '' ax2.format_coord = lambda x, y: '' ax3.format_coord = lambda x, y: '' # Adjust canvas size so that quiz below is visible (hack for interactive backend) # NOT WORKING # self.fig.set_size_inches([10,4], forward = True) ###--- First display the clothesline ax1.set_title('Suspended object ({} kg)'.format(self.m_object)) # Fix graph to problem boundaries ymargin = .06 xmargin = .4 ax1.set_ylim(bottom = self.y_origin - ymargin) # limit bottom of y axis to ground ax1.set_ylim(top = self.y_origin + self.height + ymargin) # limit top of y axis to values just above height # Customize graph style so that it doesn't look like a graph ax1.grid(False) # hide the grid ax1.set_ylabel("Height ($m$)") # add a label on the y axis ax1.get_xaxis().set_visible(False) # hide x axis ax1.spines['top'].set_visible(False) # hide the frame ax1.spines['bottom'].set_visible(False) ax1.spines['right'].set_visible(False) ax1.spines['left'].set_visible(False) # Draw the poles x_pole1 = np.array([self.x_origin, self.x_origin]) y_pole1 = np.array([self.y_origin, self.y_origin+self.height]) ax1.plot(x_pole1, y_pole1, "k-", linewidth=7, zorder=1) x_pole2 = np.array([self.x_origin+self.distance, self.x_origin+self.distance]) y_pole2 = np.array([self.y_origin, self.y_origin+self.height]) ax1.plot(x_pole2, y_pole2, "k-", linewidth=7, zorder=1) # Draw the ground ax1.axhline(y=self.y_origin, color='black', linewidth=1, zorder=2) ax1.add_patch(pat.Polygon([[self.x_origin-xmargin, self.y_origin], [self.x_origin+self.distance+xmargin, self.y_origin], [self.x_origin+self.distance+xmargin, self.y_origin-ymargin], [self.x_origin-xmargin, self.y_origin-ymargin]], closed=True, fill="white", facecolor="white", edgecolor="gray", hatch='///', linewidth=0.0, zorder=2)) # Draw the horizon line ax1.axhline(y=self.y_origin+self.height, color='gray', linestyle='-.', linewidth=1, zorder=1) # -DYN- Draw the hanging cable x = np.array([self.x_origin, coord_object[0], self.x_origin+self.distance]) y = np.array([self.y_origin+self.height, coord_object[1], self.y_origin+self.height]) self.cable, = ax1.plot(x, y, linewidth=2, linestyle = "-", color="black") # -DYN- Draw the angle between the hanging cable and horizonline ellipse_radius = 0.2 fig_ratio = self.height / self.distance self.cable_angle = pat.Arc(xy = (self.x_origin, self.y_origin+self.height), width = ellipse_radius/fig_ratio, height = ellipse_radius, theta1 = -1*alpha_degrees, theta2 = 0, color="gray", linestyle='-.') ax1.add_patch(self.cable_angle) self.cable_angle_text = ax1.annotate(alpha_text, xy=(self.x_origin, self.y_origin+self.height), xytext=(30, -15), textcoords='offset points', bbox=dict(boxstyle="round", facecolor = "white", edgecolor = "white", alpha = 0.8)) # -DYN- Draw the point at which the object is suspended self.cable_point = ax1.scatter(coord_object[0], coord_object[1], s=80, c="black", zorder=15) self.cable_point_text = ax1.annotate(height_text, xy=(coord_object[0], coord_object[1]), xytext=(10, -10), textcoords='offset points', bbox=dict(boxstyle="round", facecolor = "white", edgecolor = "white", alpha = 0.8)) # -DYN- Draw the force vectors # Parameters for drawing forces self.gravity = 9.81 self.force_scaling = .01 # Weight Fy = self.m_object*self.gravity*self.force_scaling self.cable_weight = ax1.quiver(coord_object[0], coord_object[1], 0, -Fy, color='blue', angles='xy', scale_units='xy', scale=1, zorder=12, width=0.007) self.cable_weight_text = ax1.annotate(r'$\vec{F}$', xy=(coord_object[0], coord_object[1]), xytext=(10, -55), textcoords='offset points', color='blue') # 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.cable_tension_right = ax1.quiver(coord_object[0], coord_object[1], Tx, Ty, color='red', angles='xy', scale_units='xy', scale=1, zorder=12, width=0.007, linewidth=1) self.cable_tension_right_text = ax1.annotate(r'$\vec{T}$', xy=(coord_object[0], coord_object[1]), xytext=(40, 5), textcoords='offset points', color='red') self.cable_tension_left = ax1.quiver(coord_object[0], coord_object[1], -Tx, Ty, color='red', angles='xy', scale_units='xy', scale=1, zorder=12, width=0.007, linewidth=1) self.cable_tension_left_text = ax1.annotate(r'$\vec{T}$', xy=(coord_object[0], coord_object[1]), xytext=(-45, 5), textcoords='offset points', color='red') self.cable_tension_sum = ax1.quiver(coord_object[0], coord_object[1], 0, Fy, color='red', angles='xy', scale_units='xy', scale=1, zorder=12, width=0.007, facecolor="none", edgecolor="red", hatch="/"*8, linewidth=0.0) self.cable_tension_sum_text = ax1.annotate(r'$\vec{T_r}$', xy=(coord_object[0], coord_object[1]), xytext=(10, 45), textcoords='offset points', color='red') ###--- 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 = np.linspace(self.m_counterweight_min, self.m_counterweight_max, 100) # Compute the angle (in degrees) and height for all these values angle = [] height = [] for m in m_cw: a = self.get_angle(m) angle.append(a*180/np.pi) c = self.get_object_coords(a) height.append(c[1]) # Display the functions on the graphs ax2.set_title(r'Height ($m$)') ax2.set_xlabel('Mass of the counterweight (kg)') ax2.plot(m_cw, height, "green") ax3.set_title(r'Angle $\alpha$ ($^\circ$)') ax3.set_xlabel('Mass of the counterweight (kg)') ax3.plot(m_cw, angle, "green") # Draw the horizon lines ax2.axhline(y=self.y_origin+self.height, color='gray', linestyle='-.', linewidth=1, zorder=1) ax3.axhline(y=self.y_origin, color='gray', linestyle='-.', linewidth=1, zorder=1) # -DYN- Add the current height from the counterweight selected by the user self.graph_height_point = ax2.scatter(self.m_counterweight, coord_object[1], s=80, c="black", zorder=15) self.graph_height_text = ax2.annotate(height_text, xy=(self.m_counterweight, coord_object[1]), xytext=(10, -10), textcoords='offset points', bbox=dict(boxstyle="round", facecolor = "white", edgecolor = "white", alpha = 0.8)) # -DYN- Add the current angle from the counterweight selected by the user self.graph_angle_point = ax3.scatter(self.m_counterweight, alpha_degrees, s=80, c="black", zorder=15) self.graph_angle_text = ax3.annotate(alpha_text, xy=(self.m_counterweight, alpha_degrees), xytext=(10, 5), textcoords='offset points', bbox=dict(boxstyle="round", facecolor = "white", edgecolor = "white", alpha = 0.8)) ###--- Display the whole interface display(self.m_counterweight_input) # Hack: (brutal and not great, executes only once) hide the toolbar and the top and bottom part of figure using javascript and jquery js = """ """ display(HTML(js)); # 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 on considered the ground for all values of the angle :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] # Event handler def m_counterweight_event_handler(self, change): self.m_counterweight = change.new # Compute new values with the counterweight selected by the user alpha = self.get_angle(self.m_counterweight) alpha_degrees = alpha*180/np.pi alpha_text = r'$\alpha$ = {:.2f} $^\circ$'.format(alpha_degrees) coord_object = self.get_object_coords(alpha) height_text = r'h = {:.2f} $m$'.format(coord_object[1]) ### Update the clothesline figure # Update of the cable line x = np.array([self.x_origin, coord_object[0], self.x_origin+self.distance]) y = np.array([self.y_origin+self.height, coord_object[1], self.y_origin+self.height]) self.cable.set_xdata(x) self.cable.set_ydata(y) # Update of the point self.cable_point.set_offsets(coord_object) self.cable_point_text.set_text(height_text) self.cable_point_text.xy = (coord_object[0], coord_object[1]) # Update of the angle self.cable_angle.theta1 = -1*alpha_degrees self.cable_angle_text.set_text(alpha_text) # Update the weight position (direction does not change) self.cable_weight.set_offsets(coord_object) self.cable_weight_text.xy = (coord_object[0], coord_object[1]) # Update the tension position and directions Tx = ((self.m_object*self.gravity) / (2*np.tan(alpha)))*self.force_scaling Ty = .5*self.m_object*self.gravity*self.force_scaling self.cable_tension_right.set_offsets(coord_object) self.cable_tension_right.set_UVC(Tx, Ty) self.cable_tension_right_text.xy = (coord_object[0], coord_object[1]) self.cable_tension_left.set_offsets(coord_object) self.cable_tension_left.set_UVC(-Tx, Ty) self.cable_tension_left_text.xy = (coord_object[0], coord_object[1]) self.cable_tension_sum.set_offsets(coord_object) self.cable_tension_sum_text.xy = (coord_object[0], coord_object[1]) ### Update the other two graphs # Update point of angle self.graph_angle_point.set_offsets([self.m_counterweight, alpha_degrees]) self.graph_angle_text.set_text(alpha_text) self.graph_angle_text.xy = (self.m_counterweight, alpha_degrees) # Update point of height self.graph_height_point.set_offsets([self.m_counterweight, coord_object[1]]) self.graph_height_text.set_text(height_text) self.graph_height_text.xy = (self.m_counterweight, coord_object[1]) # Display graph #self.fig.canvas.draw_idle() # EOF