~/standts-pdf/generate_standts_pdf.py
"""
Stand-to-Sit Biomechanics - Visual PDF with Stick Diagrams
Generates a professional A4 PDF with annotated stick figures for each StandTS phase.
"""
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.patches import FancyArrowPatch, Arc
from matplotlib.lines import Line2D
import numpy as np
from reportlab.lib.pagesizes import A4
from reportlab.lib import colors
from reportlab.lib.units import cm, mm
from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, Image,
Table, TableStyle, HRFlowable, PageBreak)
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_JUSTIFY
import io
import os
OUT_DIR = "/home/daytona/workspace/standts-pdf"
os.makedirs(OUT_DIR, exist_ok=True)
# ─────────────────────────────────────────────
# COLOUR PALETTE
# ─────────────────────────────────────────────
C_BONE = "#2C3E50" # dark navy – skeleton lines
C_JOINT = "#E74C3C" # red – joint dots
C_HEAD = "#F39C12" # amber – head circle
C_ARROW = "#27AE60" # green – moment/force arrows
C_CHAIR = "#95A5A6" # grey – chair
C_FLOOR = "#7F8C8D" # darker grey– floor
C_SHADE = "#EBF5FB" # light blue – panel bg
C_ACCENT = "#2980B9" # blue – accent lines
C_MUSCLE = "#8E44AD" # purple – muscle label arrows
C_GRF = "#E67E22" # orange – GRF arrow
PHASE_COLORS = ["#1ABC9C", "#3498DB", "#E74C3C", "#9B59B6"]
# ─────────────────────────────────────────────
# STICK FIGURE DRAWING HELPER
# ─────────────────────────────────────────────
def draw_stick_figure(ax, joints, chair_h=None, show_chair=True,
grf_arrow=True, moment_joints=None,
muscle_labels=None, com_pos=None,
angle_labels=None):
"""
joints: dict with keys = joint names, values = (x, y) in axis units
Expected keys: ankle, knee, hip, shoulder, elbow, wrist, neck, head_c
also: foot_heel, foot_toe
"""
ax.set_xlim(-1.2, 1.8)
ax.set_ylim(-0.15, 2.3)
ax.set_aspect('equal')
ax.axis('off')
ax.set_facecolor(C_SHADE)
lw = 3.2 # bone line width
js = 80 # joint scatter size
# ── Floor ──
ax.axhline(0, color=C_FLOOR, lw=2.5, zorder=0)
ax.fill_between([-1.2, 1.8], [-0.15, -0.15], [0, 0],
color="#BDC3C7", alpha=0.5, zorder=0)
# ── Chair ──
if show_chair and chair_h is not None:
seat_y = chair_h
seat_x0, seat_x1 = -0.05, 0.85
back_x = seat_x0
# seat surface
ax.plot([seat_x0, seat_x1], [seat_y, seat_y],
color=C_CHAIR, lw=5, solid_capstyle='round', zorder=1)
# seat back
ax.plot([back_x, back_x], [seat_y, seat_y + 0.55],
color=C_CHAIR, lw=5, solid_capstyle='round', zorder=1)
# legs
for lx in [seat_x0 + 0.05, seat_x1 - 0.05]:
ax.plot([lx, lx], [0, seat_y],
color=C_CHAIR, lw=3, zorder=1)
j = joints # shorthand
def bone(a, b, **kw):
ax.plot([j[a][0], j[b][0]], [j[a][1], j[b][1]],
color=kw.get('color', C_BONE),
lw=kw.get('lw', lw),
solid_capstyle='round', zorder=3)
# ── Skeleton segments ──
# Foot
bone('foot_heel', 'ankle')
bone('ankle', 'foot_toe')
# Lower leg
bone('ankle', 'knee')
# Upper leg
bone('knee', 'hip')
# Trunk
bone('hip', 'shoulder')
# Upper arm
bone('shoulder', 'elbow')
# Forearm
bone('elbow', 'wrist')
# Neck
bone('shoulder', 'neck')
# ── Head ──
head_r = 0.10
head = plt.Circle(j['head_c'], head_r, color=C_HEAD,
ec=C_BONE, lw=1.5, zorder=4)
ax.add_patch(head)
# ── Joints ──
joint_pts = ['ankle', 'knee', 'hip', 'shoulder', 'elbow', 'wrist', 'neck']
xs = [j[k][0] for k in joint_pts]
ys = [j[k][1] for k in joint_pts]
ax.scatter(xs, ys, s=js, color=C_JOINT, zorder=5, edgecolors='white', lw=1)
# ── GRF Arrow ──
if grf_arrow:
ankle_x = j['ankle'][0]
grf_h = 0.55
ax.annotate("", xy=(ankle_x, grf_h), xytext=(ankle_x, 0),
arrowprops=dict(arrowstyle="-|>",
color=C_GRF, lw=2.2,
mutation_scale=18))
ax.text(ankle_x + 0.12, grf_h / 2, "GRF",
color=C_GRF, fontsize=7, fontweight='bold', va='center')
# ── COM marker ──
if com_pos:
ax.plot(com_pos[0], com_pos[1], marker='*', markersize=14,
color=C_ACCENT, zorder=6,
markeredgecolor='white', markeredgewidth=0.8)
ax.text(com_pos[0] + 0.12, com_pos[1],
"COM", color=C_ACCENT, fontsize=7, fontweight='bold', va='center')
# ── Moment arcs ──
if moment_joints:
for mj, direction, label in moment_joints:
cx, cy = j[mj]
r = 0.22
t1, t2 = (20, 160) if direction == 'ext' else (200, 340)
arc = Arc((cx, cy), 2*r, 2*r, angle=0,
theta1=t1, theta2=t2,
color=C_ARROW, lw=2, zorder=6)
ax.add_patch(arc)
# arrowhead at end of arc
mid_ang = np.radians((t1 + t2) / 2)
ax.text(cx + (r+0.07)*np.cos(mid_ang),
cy + (r+0.07)*np.sin(mid_ang),
label, color=C_ARROW, fontsize=6.5,
fontweight='bold', ha='center', va='center')
# ── Angle labels ──
if angle_labels:
for jname, angle_txt, offset in angle_labels:
cx, cy = j[jname]
ax.text(cx + offset[0], cy + offset[1],
angle_txt, color="#2C3E50",
fontsize=6.5, ha='center', va='center',
bbox=dict(boxstyle='round,pad=0.2',
fc='white', ec='#BDC3C7', alpha=0.85))
# ── Muscle labels ──
if muscle_labels:
for mname, pos, jname in muscle_labels:
mx, my = pos
jx, jy = j[jname]
ax.annotate(mname,
xy=(jx, jy), xytext=(mx, my),
fontsize=6, color=C_MUSCLE, fontweight='bold',
arrowprops=dict(arrowstyle='-',
color=C_MUSCLE, lw=1.2,
linestyle='dashed'),
ha='center', va='center',
bbox=dict(boxstyle='round,pad=0.15',
fc='#F8F9FA', ec=C_MUSCLE, alpha=0.9))
# ─────────────────────────────────────────────
# DEFINE JOINT POSITIONS FOR EACH PHASE
# ─────────────────────────────────────────────
CHAIR_H = 0.52 # seat height
def phase1_joints():
"""Phase 1: Standing upright, just beginning forward trunk lean"""
ankle = (0.30, 0.08)
knee = (0.30, 0.58)
hip = (0.30, 1.05)
# Trunk leans ~15° forward
trunk_len = 0.50
trunk_angle = np.radians(80) # nearly upright, slight lean
shoulder = (hip[0] + trunk_len * np.cos(np.pi/2 - np.radians(10)),
hip[1] + trunk_len * np.sin(np.pi/2 - np.radians(10)))
neck = (shoulder[0] + 0.00, shoulder[1] + 0.10)
head_c = (neck[0] + 0.01, neck[1] + 0.20)
elbow = (shoulder[0] + 0.18, shoulder[1] - 0.18)
wrist = (shoulder[0] + 0.30, shoulder[1] - 0.35)
foot_heel = (ankle[0] - 0.15, 0.01)
foot_toe = (ankle[0] + 0.18, 0.01)
return dict(ankle=ankle, knee=knee, hip=hip, shoulder=shoulder,
neck=neck, head_c=head_c, elbow=elbow, wrist=wrist,
foot_heel=foot_heel, foot_toe=foot_toe)
def phase2_joints():
"""Phase 2: Controlled descent - trunk inclined, knees bent ~45°"""
ankle = (0.28, 0.08)
knee = (0.42, 0.52)
hip = (0.20, 0.92)
# Trunk leaning ~30° forward
trunk_len = 0.50
shoulder = (hip[0] + trunk_len * np.sin(np.radians(30)),
hip[1] + trunk_len * np.cos(np.radians(30)))
neck = (shoulder[0] + 0.02, shoulder[1] + 0.10)
head_c = (neck[0] + 0.04, neck[1] + 0.19)
elbow = (shoulder[0] + 0.12, shoulder[1] - 0.20)
wrist = (shoulder[0] + 0.18, shoulder[1] - 0.38)
foot_heel = (ankle[0] - 0.15, 0.01)
foot_toe = (ankle[0] + 0.18, 0.01)
return dict(ankle=ankle, knee=knee, hip=hip, shoulder=shoulder,
neck=neck, head_c=head_c, elbow=elbow, wrist=wrist,
foot_heel=foot_heel, foot_toe=foot_toe)
def phase3_joints():
"""Phase 3: Seat contact - buttocks touching chair, knees ~90°"""
ankle = (0.25, 0.08)
knee = (0.55, 0.50)
hip = (0.42, CHAIR_H)
# Trunk more upright after seat contact
trunk_len = 0.52
shoulder = (hip[0] + trunk_len * np.sin(np.radians(20)),
hip[1] + trunk_len * np.cos(np.radians(20)))
neck = (shoulder[0] + 0.00, shoulder[1] + 0.10)
head_c = (neck[0] + 0.02, neck[1] + 0.19)
elbow = (shoulder[0] + 0.10, shoulder[1] - 0.22)
wrist = (shoulder[0] + 0.14, shoulder[1] - 0.40)
foot_heel = (ankle[0] - 0.14, 0.01)
foot_toe = (ankle[0] + 0.18, 0.01)
return dict(ankle=ankle, knee=knee, hip=hip, shoulder=shoulder,
neck=neck, head_c=head_c, elbow=elbow, wrist=wrist,
foot_heel=foot_heel, foot_toe=foot_toe)
def phase4_joints():
"""Phase 4: Fully seated, stabilized upright"""
ankle = (0.22, 0.08)
knee = (0.58, 0.50)
hip = (0.50, CHAIR_H + 0.02)
# Trunk upright
trunk_len = 0.54
shoulder = (hip[0] + trunk_len * np.sin(np.radians(5)),
hip[1] + trunk_len * np.cos(np.radians(5)))
neck = (shoulder[0] + 0.00, shoulder[1] + 0.10)
head_c = (neck[0] + 0.01, neck[1] + 0.19)
elbow = (shoulder[0] - 0.12, shoulder[1] - 0.18)
wrist = (shoulder[0] - 0.20, shoulder[1] - 0.35)
foot_heel = (ankle[0] - 0.12, 0.01)
foot_toe = (ankle[0] + 0.18, 0.01)
return dict(ankle=ankle, knee=knee, hip=hip, shoulder=shoulder,
neck=neck, head_c=head_c, elbow=elbow, wrist=wrist,
foot_heel=foot_heel, foot_toe=foot_toe)
# ─────────────────────────────────────────────
# PHASE METADATA
# ─────────────────────────────────────────────
PHASES = [
{
"num": 1,
"name": "Phase 1 – Initiation / Forward Lean",
"joints_fn": phase1_joints,
"show_chair": True,
"grf": True,
"com": (0.62, 1.10),
"moments": [
("knee", "ext", "Ext\nMoment"),
("hip", "ext", "Ext\nMoment"),
],
"muscles": [
("Quadriceps\n(begin eccentric)", (-0.55, 0.85), "knee"),
("Glut. Max\n(begin eccentric)", (-0.55, 1.30), "hip"),
("Erector\nSpinae", (1.30, 1.50), "shoulder"),
],
"angles": [
("knee", "~0°\nknee flex", (0.30, 0.35)),
("hip", "~5°\nhip flex", (0.62, 1.05)),
("ankle","~0°\nneutral", (-0.15, 0.25)),
],
"color": PHASE_COLORS[0],
"key_points": [
"Trunk begins to incline forward",
"COM shifts anteriorly over BOS",
"Minimal joint flexion at start",
"Erector spinae active isometrically",
"GRF = ~100% body weight (BW)",
"Quadriceps begin eccentric pre-activation",
],
"kinetics": "GRF ≈ 100% BW | Knee moment: low | Hip moment: low",
"kinematics": "Knee: 0° | Hip: 0-5° flex | Ankle: neutral",
"muscle_action": "Eccentric activation begins (quads, glut max)",
},
{
"num": 2,
"name": "Phase 2 – Controlled Descent",
"joints_fn": phase2_joints,
"show_chair": True,
"grf": True,
"com": (0.52, 1.02),
"moments": [
("knee", "ext", "PEAK\nExt Moment"),
("hip", "ext", "Ext\nMoment"),
("ankle","ext", "PF\nMoment"),
],
"muscles": [
("Quadriceps\n(MAX eccentric)", (-0.60, 0.70), "knee"),
("Glut. Max\n(eccentric)", (-0.60, 1.10), "hip"),
("Soleus\n(eccentric)", (-0.60, 0.35), "ankle"),
],
"angles": [
("knee", "~45-70°\nknee flex", (0.65, 0.38)),
("hip", "~50°\nhip flex", (0.60, 0.88)),
("ankle","~15°\ndorsiflexion",(−0.18, 0.28)),
],
"color": PHASE_COLORS[1],
"key_points": [
"Body actively lowering toward chair",
"PEAK knee extensor eccentric moment",
"COM descends and moves posteriorly",
"Quadriceps = dominant muscle (~80% eccentric control)",
"Tibial forward lean drives ankle dorsiflexion",
"GRF begins to decrease as seat approached",
],
"kinetics": "GRF decreasing | Knee moment: PEAK ~1.0-1.2 Nm/kg | Hip: ~0.7 Nm/kg",
"kinematics": "Knee: 45-70° flex | Hip: 40-60° flex | Ankle: 10-18° DF",
"muscle_action": "All lower limb muscles ECCENTRICALLY active (quads dominant)",
},
{
"num": 3,
"name": "Phase 3 – Seat Contact / Loading",
"joints_fn": phase3_joints,
"show_chair": True,
"grf": True,
"com": (0.68, 1.00),
"moments": [
("knee", "ext", "Ext\nMoment"),
("hip", "ext", "Ext\nMoment"),
],
"muscles": [
("Quadriceps\n(eccentric→iso)", (-0.58, 0.65), "knee"),
("Hamstrings\n(co-activation)", (1.30, 0.68), "knee"),
("Hip Flexors\n(stabilize)", (1.30, 1.05), "hip"),
],
"angles": [
("knee", "~90°\nknee flex", (0.72, 0.32)),
("hip", "~85°\nhip flex", (0.68, 0.70)),
("ankle","~18°\ndorsiflexion", (-0.18, 0.25)),
],
"color": PHASE_COLORS[2],
"key_points": [
"Buttocks make contact with seat surface",
"Weight rapidly transferring: feet → ischial tuberosities",
"GRF drops sharply at feet as seat bears load",
"Impact forces at hip/spine depend on descent speed",
"Quadriceps transition eccentric → isometric",
"Hamstrings co-activate for knee stability",
],
"kinetics": "GRF drops at feet | Seat impact force peaks | Knee ~90° load",
"kinematics": "Knee: ~90° | Hip: ~80-90° flex | Ankle: ~18-20° DF",
"muscle_action": "Eccentric → Isometric transition; hamstrings co-activate",
},
{
"num": 4,
"name": "Phase 4 – Stabilization / Fully Seated",
"joints_fn": phase4_joints,
"show_chair": True,
"grf": False,
"com": (0.72, 1.10),
"moments": None,
"muscles": [
("Postural\nMuscles (iso)", (-0.58, 1.40), "shoulder"),
("Hip Abductors\n(stabilize)", (1.30, 0.82), "hip"),
("Tibialis Ant.\n(stabilize)", (-0.60, 0.40), "ankle"),
],
"angles": [
("knee", "~90-110°\nknee flex", (0.72, 0.28)),
("hip", "~90-100°\nhip flex", (0.72, 0.72)),
],
"color": PHASE_COLORS[3],
"key_points": [
"Full seated position achieved",
"Body weight borne by ischial tuberosities on seat",
"GRF at feet ≈ minimal",
"Postural muscles maintain upright trunk (isometric)",
"Hip abductors prevent pelvic drop",
"Trunk erectors maintain spinal alignment",
],
"kinetics": "GRF at feet ≈ minimal | Weight on seat | All moments low",
"kinematics": "Knee: ~90-110° | Hip: ~90-100° | Ankle: relaxed",
"muscle_action": "Isometric postural stabilization; eccentric work complete",
},
]
# ─────────────────────────────────────────────
# GENERATE INDIVIDUAL PHASE FIGURE → PNG bytes
# ─────────────────────────────────────────────
def make_phase_figure(phase_data, width_in=7.5, height_in=4.0):
fig = plt.figure(figsize=(width_in, height_in), facecolor='white')
fig.patch.set_facecolor('white')
# Layout: stick fig (left) | info panel (right)
gs = fig.add_gridspec(1, 2, width_ratios=[1, 1.6],
left=0.02, right=0.98,
top=0.92, bottom=0.04, wspace=0.08)
ax_fig = fig.add_subplot(gs[0])
ax_txt = fig.add_subplot(gs[1])
phase_color = phase_data["color"]
# ── Phase title banner ──
fig.text(0.5, 0.97, phase_data["name"],
ha='center', va='top', fontsize=13, fontweight='bold',
color='white',
bbox=dict(boxstyle='round,pad=0.4', fc=phase_color,
ec='none', alpha=0.95),
transform=fig.transFigure)
# ── Stick figure ──
j = phase_data["joints_fn"]()
draw_stick_figure(
ax_fig, j,
chair_h=CHAIR_H,
show_chair=phase_data["show_chair"],
grf_arrow=phase_data["grf"],
moment_joints=phase_data["moments"],
muscle_labels=phase_data["muscles"],
com_pos=phase_data["com"],
angle_labels=phase_data["angles"],
)
# Phase number watermark in figure panel
ax_fig.text(0.02, 0.97, f"P{phase_data['num']}",
transform=ax_fig.transAxes,
fontsize=22, fontweight='black',
color=phase_color, alpha=0.25,
va='top', ha='left')
# ── Info panel ──
ax_txt.axis('off')
ax_txt.set_facecolor('white')
# Key Points box
y = 0.97
ax_txt.text(0.03, y, "KEY EVENTS", transform=ax_txt.transAxes,
fontsize=8.5, fontweight='bold', color=phase_color, va='top')
y -= 0.09
for pt in phase_data["key_points"]:
ax_txt.text(0.03, y, f" • {pt}", transform=ax_txt.transAxes,
fontsize=7.2, color='#2C3E50', va='top')
y -= 0.085
y -= 0.04
def info_block(label, value, ypos, color):
ax_txt.text(0.03, ypos, label, transform=ax_txt.transAxes,
fontsize=7.5, fontweight='bold', color=color, va='top')
ax_txt.text(0.03, ypos - 0.09, value, transform=ax_txt.transAxes,
fontsize=7, color='#34495E', va='top',
wrap=True)
return ypos - 0.19
y = info_block("KINETICS", phase_data["kinetics"], y, "#E74C3C")
y = info_block("KINEMATICS", phase_data["kinematics"], y, "#2980B9")
y = info_block("MUSCLE ACTION", phase_data["muscle_action"], y, "#8E44AD")
# ── Legend ──
legend_items = [
mpatches.Patch(color=C_JOINT, label='Joint'),
mpatches.Patch(color=C_HEAD, label='Head'),
mpatches.Patch(color=C_GRF, label='GRF Arrow'),
mpatches.Patch(color=C_ARROW, label='Net Moment Arc'),
mpatches.Patch(color=C_ACCENT, label='COM (★)'),
mpatches.Patch(color=C_MUSCLE, label='Muscle Label'),
]
ax_txt.legend(handles=legend_items, loc='lower left',
fontsize=6, framealpha=0.9, ncol=2,
bbox_to_anchor=(0.0, 0.0))
buf = io.BytesIO()
fig.savefig(buf, format='png', dpi=180, bbox_inches='tight',
facecolor='white')
plt.close(fig)
buf.seek(0)
return buf.read()
# ─────────────────────────────────────────────
# GENERATE OVERVIEW COMPARISON FIGURE (all 4)
# ─────────────────────────────────────────────
def make_overview_figure():
fig, axes = plt.subplots(1, 4, figsize=(14, 4.5),
facecolor='white')
fig.patch.set_facecolor('white')
fig.suptitle("Stand-to-Sit (StandTS) – 4-Phase Overview",
fontsize=14, fontweight='bold', color='#2C3E50', y=1.01)
phase_fns = [phase1_joints, phase2_joints, phase3_joints, phase4_joints]
labels = ["Phase 1\nInitiation", "Phase 2\nDescent",
"Phase 3\nSeat Contact", "Phase 4\nStabilization"]
grf_flags = [True, True, True, False]
for i, (ax, fn, lbl, grf, pc) in enumerate(
zip(axes, phase_fns, labels, grf_flags, PHASE_COLORS)):
j = fn()
moments = None
if i == 1: # Phase 2 - show peak moment
moments = [("knee", "ext", "PEAK")]
draw_stick_figure(ax, j, chair_h=CHAIR_H, show_chair=True,
grf_arrow=grf, moment_joints=moments,
muscle_labels=None, com_pos=None,
angle_labels=None)
ax.set_title(lbl, fontsize=9, fontweight='bold',
color='white', pad=4,
bbox=dict(boxstyle='round,pad=0.3',
fc=pc, ec='none'))
# Arrow connecting phases
for i in range(3):
fig.add_artist(mpatches.FancyArrowPatch(
((i + 1) * 0.25 - 0.01, 0.5), ((i + 1) * 0.25 + 0.01, 0.5),
arrowstyle='-|>', mutation_scale=18,
color='#95A5A6', lw=1.5,
transform=fig.transFigure, figure=fig))
plt.tight_layout(pad=0.5)
buf = io.BytesIO()
fig.savefig(buf, format='png', dpi=180, bbox_inches='tight',
facecolor='white')
plt.close(fig)
buf.seek(0)
return buf.read()
# ─────────────────────────────────────────────
# MOMENT SUMMARY CHART
# ─────────────────────────────────────────────
def make_moment_chart():
fig, ax = plt.subplots(figsize=(8, 3.5), facecolor='white')
fig.patch.set_facecolor('white')
phases_x = ["Phase 1\nInitiation", "Phase 2\nDescent",
"Phase 3\nSeat Contact", "Phase 4\nStabilization"]
knee_moments = [0.15, 1.10, 0.60, 0.05]
hip_moments = [0.10, 0.75, 0.45, 0.03]
ankle_moments = [0.05, 0.40, 0.20, 0.02]
x = np.arange(len(phases_x))
w = 0.26
b1 = ax.bar(x - w, knee_moments, w, label='Knee Extensor Moment',
color='#E74C3C', alpha=0.88, edgecolor='white')
b2 = ax.bar(x, hip_moments, w, label='Hip Extensor Moment',
color='#3498DB', alpha=0.88, edgecolor='white')
b3 = ax.bar(x + w, ankle_moments, w, label='Ankle Plantarflexor Moment',
color='#27AE60', alpha=0.88, edgecolor='white')
ax.set_xticks(x)
ax.set_xticklabels(phases_x, fontsize=8)
ax.set_ylabel("Net Joint Moment (Nm/kg)", fontsize=9)
ax.set_title("Joint Moment Demands Across StandTS Phases",
fontsize=11, fontweight='bold', color='#2C3E50')
ax.legend(fontsize=8, loc='upper right')
ax.set_ylim(0, 1.4)
ax.yaxis.grid(True, alpha=0.4, linestyle='--')
ax.set_facecolor('#F8F9FA')
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
# Annotate peak
ax.annotate("PEAK\nKnee Moment",
xy=(1 - w, 1.10), xytext=(0.5, 1.25),
fontsize=7.5, color='#E74C3C', fontweight='bold',
arrowprops=dict(arrowstyle='-|>', color='#E74C3C', lw=1.5))
plt.tight_layout()
buf = io.BytesIO()
fig.savefig(buf, format='png', dpi=180, bbox_inches='tight',
facecolor='white')
plt.close(fig)
buf.seek(0)
return buf.read()
# ─────────────────────────────────────────────
# BUILD PDF
# ─────────────────────────────────────────────
def build_pdf(out_path):
doc = SimpleDocTemplate(
out_path,
pagesize=A4,
leftMargin=1.5*cm, rightMargin=1.5*cm,
topMargin=1.8*cm, bottomMargin=1.5*cm,
title="Stand-to-Sit Biomechanics – Moment Analysis",
author="Biomechanics Assignment",
)
styles = getSampleStyleSheet()
W = A4[0] - 3*cm # usable width
def H(tag, txt):
style = ParagraphStyle(
tag,
fontSize={'h1':16,'h2':12,'h3':10,'body':8.5,'small':7.5}[tag],
leading={'h1':20,'h2':16,'h3':14,'body':12,'small':10}[tag],
textColor={'h1':'#2C3E50','h2':'#2980B9','h3':'#8E44AD',
'body':'#34495E','small':'#7F8C8D'}[tag],
fontName={'h1':'Helvetica-Bold','h2':'Helvetica-Bold',
'h3':'Helvetica-Bold','body':'Helvetica',
'small':'Helvetica'}[tag],
spaceAfter={'h1':8,'h2':6,'h3':4,'body':4,'small':3}[tag],
alignment=TA_LEFT,
)
return Paragraph(txt, style)
def centre_img(png_bytes, w_cm=None, h_cm=None):
buf = io.BytesIO(png_bytes)
img = Image(buf)
if w_cm:
img.drawWidth = w_cm * cm
img.drawHeight = (img.imageHeight / img.imageWidth) * w_cm * cm
if h_cm:
img.drawHeight = h_cm * cm
img.drawWidth = (img.imageWidth / img.imageHeight) * h_cm * cm
return img
CENTER = ParagraphStyle('center', parent=styles['Normal'],
alignment=TA_CENTER, fontSize=8,
textColor=colors.HexColor('#7F8C8D'))
TITLE_STYLE = ParagraphStyle('title', fontSize=20, fontName='Helvetica-Bold',
textColor=colors.HexColor('#2C3E50'),
alignment=TA_CENTER, spaceAfter=6)
SUB_STYLE = ParagraphStyle('sub', fontSize=11, fontName='Helvetica',
textColor=colors.HexColor('#7F8C8D'),
alignment=TA_CENTER, spaceAfter=4)
story = []
# ── COVER / TITLE ──
story.append(Spacer(1, 1.5*cm))
story.append(Paragraph("STAND-TO-SIT (StandTS)", TITLE_STYLE))
story.append(Paragraph("Moment Analysis in Biomechanics", TITLE_STYLE))
story.append(Spacer(1, 0.3*cm))
story.append(Paragraph("Visual Guide with Stick Diagrams — Phase-by-Phase Analysis",
SUB_STYLE))
story.append(HRFlowable(width='100%', thickness=2,
color=colors.HexColor('#2980B9'),
spaceAfter=8))
story.append(Spacer(1, 0.4*cm))
# ── OVERVIEW FIGURE ──
story.append(H('h2', "Phase Overview: Stand → Sit"))
story.append(Spacer(1, 0.2*cm))
overview_png = make_overview_figure()
story.append(centre_img(overview_png, w_cm=17))
story.append(Paragraph(
"Figure 1: All four phases of Stand-to-Sit showing stick figure postures, "
"ground reaction force (GRF) arrows, and peak moment arc (Phase 2).",
CENTER))
story.append(Spacer(1, 0.4*cm))
# ── PHASE OVERVIEW TABLE ──
story.append(H('h2', "Phase Summary Table"))
tbl_data = [
["Phase", "Name", "Trigger Event", "Key Joint Motion", "Dominant Muscle Action"],
["1", "Initiation /\nForward Lean",
"Start of trunk\nincline forward",
"Hip flex begins;\nKnee neutral",
"Eccentric pre-activation\n(quads, glut max)"],
["2", "Controlled\nDescent",
"Hip lift-off to\npre-seat contact",
"Knee: 0→70° flex\nHip: 0→60° flex\nAnkle: 0→18° DF",
"PEAK eccentric:\nQuadriceps (~80%)\nGlut. Max, Soleus"],
["3", "Seat Contact /\nLoading",
"Buttocks touch seat\nto weight transfer",
"Knee: ~90° flex\nHip: ~90° flex",
"Eccentric → Isometric\nHamstrings co-activate"],
["4", "Stabilization /\nSettling",
"Full seated position\nachieved",
"All joints stable\nat full flexion",
"Isometric postural\nstabilization"],
]
tbl = Table(tbl_data, colWidths=[1.0*cm, 2.8*cm, 3.2*cm, 3.8*cm, 4.2*cm])
tbl.setStyle(TableStyle([
('BACKGROUND', (0,0), (-1,0), colors.HexColor('#2980B9')),
('TEXTCOLOR', (0,0), (-1,0), colors.white),
('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'),
('FONTSIZE', (0,0), (-1,0), 8),
('ALIGN', (0,0), (-1,-1), 'CENTER'),
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
('FONTSIZE', (0,1), (-1,-1), 7.5),
('ROWBACKGROUNDS',(0,1),(-1,-1),
[colors.HexColor('#EBF5FB'), colors.white]),
('GRID', (0,0), (-1,-1), 0.5, colors.HexColor('#BDC3C7')),
('TOPPADDING', (0,0), (-1,-1), 5),
('BOTTOMPADDING',(0,0), (-1,-1), 5),
('BACKGROUND', (0,2), (-1,2), colors.HexColor('#D6EAF8')), # highlight phase 2
]))
story.append(tbl)
story.append(Spacer(1, 0.5*cm))
# ── INDIVIDUAL PHASE PAGES ──
for pd_ in PHASES:
story.append(PageBreak())
story.append(H('h2', pd_["name"]))
story.append(HRFlowable(width='100%', thickness=1.5,
color=colors.HexColor(pd_["color"]),
spaceAfter=6))
phase_png = make_phase_figure(pd_)
story.append(centre_img(phase_png, w_cm=16.5))
story.append(Paragraph(
f"Figure: Phase {pd_['num']} – {pd_['name'].split('–')[1].strip()}. "
"Stick figure shows joint positions, net moment arcs (green), "
"GRF arrow (orange), COM (★ blue), and eccentric muscle labels (purple).",
CENTER))
story.append(Spacer(1, 0.3*cm))
# Detail table per phase
detail_data = [
["KINETICS", pd_["kinetics"]],
["KINEMATICS", pd_["kinematics"]],
["MUSCLE ACTION", pd_["muscle_action"]],
]
dt = Table(detail_data, colWidths=[3.5*cm, 12.5*cm])
dt.setStyle(TableStyle([
('FONTNAME', (0,0), (0,-1), 'Helvetica-Bold'),
('FONTSIZE', (0,0), (-1,-1), 8),
('TEXTCOLOR', (0,0), (0,-1), colors.HexColor(pd_["color"])),
('GRID', (0,0), (-1,-1), 0.5, colors.HexColor('#D5D8DC')),
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
('TOPPADDING',(0,0), (-1,-1), 5),
('BOTTOMPADDING',(0,0), (-1,-1), 5),
('ROWBACKGROUNDS',(0,0),(-1,-1),
[colors.HexColor('#FDFEFE'), colors.HexColor('#EBF5FB'),
colors.HexColor('#F4ECF7')]),
]))
story.append(dt)
# ── MOMENT CHART PAGE ──
story.append(PageBreak())
story.append(H('h2', "Joint Moment Analysis – Across All Phases"))
story.append(HRFlowable(width='100%', thickness=1.5,
color=colors.HexColor('#E74C3C'), spaceAfter=6))
moment_png = make_moment_chart()
story.append(centre_img(moment_png, w_cm=16))
story.append(Paragraph(
"Figure: Net joint moment estimates (Nm/kg body mass) at knee, hip, and ankle "
"across the four StandTS phases. Phase 2 shows the highest demands, particularly "
"at the knee (peak ~1.0–1.2 Nm/kg). Values are approximate normative references.",
CENTER))
story.append(Spacer(1, 0.5*cm))
# Moment analysis table
story.append(H('h3', "Moment Analysis Summary Table"))
ma_data = [
["Joint", "External Moment\n(Gravity)", "Internal Moment\n(Muscle)", "Peak Phase", "Peak Value"],
["Knee", "Flexion moment\n(gravity-driven)",
"Extension moment\n(Quadriceps eccentric)", "Phase 2", "~1.0–1.2 Nm/kg"],
["Hip", "Flexion moment\n(BW + trunk load)",
"Extension moment\n(Glut Max, Hamstrings)", "Phase 2", "~0.5–0.9 Nm/kg"],
["Ankle", "Dorsiflexion moment\n(tibia forward lean)",
"Plantarflexion moment\n(Soleus, Gastroc)", "Phase 2", "~0.3–0.5 Nm/kg"],
["Trunk", "Flexion moment\n(forward lean gravity)",
"Extension moment\n(Erector Spinae)", "Phase 1–2", "High (variable)"],
]
mat = Table(ma_data, colWidths=[2.2*cm, 4.0*cm, 4.0*cm, 2.5*cm, 3.3*cm])
mat.setStyle(TableStyle([
('BACKGROUND', (0,0), (-1,0), colors.HexColor('#E74C3C')),
('TEXTCOLOR', (0,0), (-1,0), colors.white),
('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'),
('FONTSIZE', (0,0), (-1,0), 8),
('ALIGN', (0,0), (-1,-1), 'CENTER'),
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
('FONTSIZE', (0,1), (-1,-1), 7.5),
('ROWBACKGROUNDS',(0,1),(-1,-1),
[colors.HexColor('#FDEDEC'), colors.white]),
('GRID', (0,0), (-1,-1), 0.5, colors.HexColor('#BDC3C7')),
('TOPPADDING', (0,0), (-1,-1), 5),
('BOTTOMPADDING',(0,0), (-1,-1), 5),
]))
story.append(mat)
story.append(Spacer(1, 0.5*cm))
# Key Equations box
story.append(H('h3', "Key Biomechanical Equations"))
eq_data = [
["Torque / Moment", "M = F × d⊥", "F = applied force; d⊥ = perpendicular moment arm"],
["Muscle Force", "F_m = (W × d_ext) / d_m",
"d_ext = external moment arm; d_m = muscle moment arm"],
["Eccentric Work", "W_ecc = ∫ F·dθ (negative)",
"Energy absorbed; muscle lengthens under load"],
["GRF Impulse", "J = ∫ GRF·dt",
"Area under force-time curve; equals change in momentum"],
["Newton's 3rd", "F_seat = −F_body",
"Seat reaction force equals body weight at full sit"],
]
eqt = Table(eq_data, colWidths=[3.5*cm, 4.5*cm, 8.0*cm])
eqt.setStyle(TableStyle([
('BACKGROUND', (0,0), (-1,0), colors.HexColor('#8E44AD')),
('TEXTCOLOR', (0,0), (-1,0), colors.white),
('FONTNAME', (0,0), (1,-1), 'Courier-Bold'),
('FONTNAME', (0,0), (0,-1), 'Helvetica-Bold'),
('FONTSIZE', (0,0), (-1,-1), 7.5),
('ALIGN', (0,0), (-1,-1), 'LEFT'),
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
('ROWBACKGROUNDS',(0,0),(-1,-1),
[colors.HexColor('#F4ECF7'), colors.HexColor('#FDFEFE')]),
('GRID', (0,0), (-1,-1), 0.5, colors.HexColor('#BDC3C7')),
('TOPPADDING', (0,0), (-1,-1), 5),
('BOTTOMPADDING',(0,0), (-1,-1), 5),
]))
story.append(eqt)
story.append(Spacer(1, 0.5*cm))
# ── FUNCTIONAL SIGNIFICANCE PAGE ──
story.append(PageBreak())
story.append(H('h2', "Functional Significance of StandTS Moment Analysis"))
story.append(HRFlowable(width='100%', thickness=1.5,
color=colors.HexColor('#1ABC9C'), spaceAfter=6))
fs_data = [
["Clinical Aspect", "Biomechanical Basis", "Implication"],
["Fall Prevention",
"Eccentric quadriceps weakness → uncontrolled descent",
"Strengthen knee extensors eccentrically in elderly"],
["Chair Height",
"Higher chair → smaller knee/hip moment arm → less torque demand",
"Raise chair height in OA / post-surgical rehab"],
["Trunk Lean",
"More forward lean shifts COM; reduces knee extensor moment",
"Teach forward lean strategy to compensate for quad weakness"],
["TKR / THR Rehab",
"StandTS is benchmark functional task post-surgery",
"Target eccentric quad control in rehab protocol"],
["Osteoarthritis",
"Altered joint moments due to pain; compensatory strategies",
"Monitor for lateral trunk shift, reduced descent speed"],
["Obesity",
"↑ body weight → proportionally ↑ all joint moments",
"Weight management reduces musculoskeletal joint load"],
["5XSTS Test",
"5-times sit-to-stand speed correlates with quad strength",
"Standardized fall-risk assessment tool"],
["Parkinson's",
"Impaired eccentric control, timing, and COM transfer",
"Retrain controlled descent; use cueing strategies"],
]
fst = Table(fs_data, colWidths=[4.0*cm, 6.0*cm, 6.0*cm])
fst.setStyle(TableStyle([
('BACKGROUND', (0,0), (-1,0), colors.HexColor('#1ABC9C')),
('TEXTCOLOR', (0,0), (-1,0), colors.white),
('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'),
('FONTSIZE', (0,0), (-1,0), 8),
('ALIGN', (0,0), (-1,-1), 'LEFT'),
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
('FONTSIZE', (0,1), (-1,-1), 7.5),
('ROWBACKGROUNDS',(0,1),(-1,-1),
[colors.HexColor('#E8F8F5'), colors.white]),
('GRID', (0,0), (-1,-1), 0.5, colors.HexColor('#BDC3C7')),
('TOPPADDING', (0,0), (-1,-1), 5),
('BOTTOMPADDING',(0,0), (-1,-1), 5),
]))
story.append(fst)
doc.build(story)
print(f"PDF saved: {out_path}")
# ─────────────────────────────────────────────
# RUN
# ─────────────────────────────────────────────
if __name__ == "__main__":
out = os.path.join(OUT_DIR, "StandTS_Moment_Analysis.pdf")
build_pdf(out)