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