"""Frontier F01 lab: feedback control for a hovering drone and a path-following car.Python 3.13 standard library only. SI units throughout: metres, seconds,kilograms, newtons, radians.Two plants share one idea: measure, compare with a target, and push thesystem toward the target every tick.* ``simulate_hover`` flies a drone straight up to a target altitude with a PID controller whose thrust is limited to what the motors can produce.* ``simulate_pure_pursuit`` drives a kinematic bicycle-model car along a path by steering toward a point one look-ahead distance ahead."""from__future__importannotationsimportmathimportrandomfromdataclassesimportdataclassG=9.81# gravitational acceleration, m/s^2# ---------------------------------------------------------------------------# PID controller# ---------------------------------------------------------------------------@dataclassclassPID:"""Discrete PID controller. The derivative acts on the *measurement*, not the error, so a sudden setpoint change does not produce a spike ("derivative kick"). Conditional integration stops the integral from growing while the output is pinned at a limit and the error would push it further past that limit (anti-windup). """kp:floatki:floatkd:floatout_min:float=-math.infout_max:float=math.infanti_windup:bool=Trueintegral:float=0.0prev_measurement:float|None=Nonedefreset(self)->None:self.integral=0.0self.prev_measurement=Nonedefupdate(self,setpoint:float,measurement:float,dt:float)->float:ifdt<=0.0:raiseValueError("dt must be positive")error=setpoint-measurementifself.prev_measurementisNone:measurement_rate=0.0else:measurement_rate=(measurement-self.prev_measurement)/dtself.prev_measurement=measurementcandidate=self.integral+error*dtraw=self.kp*error+self.ki*candidate-self.kd*measurement_rateout=min(max(raw,self.out_min),self.out_max)pinned_high=out>=self.out_maxanderror>0.0pinned_low=out<=self.out_minanderror<0.0ifnotself.anti_windupornot(pinned_highorpinned_low):self.integral=candidatereturnout# ---------------------------------------------------------------------------# Drone altitude hold# ---------------------------------------------------------------------------@dataclass(frozen=True)classHoverSample:t:float# time, sz:float# true altitude, mvz:float# vertical velocity, m/sthrust:float# commanded total thrust, Ndefsimulate_hover(kp:float,ki:float,kd:float,*,mass:float=1.5,drag:float=0.3,setpoint:float=10.0,duration:float=10.0,dt:float=0.01,feedforward_mass:float|None=None,payload:float=0.0,max_thrust_ratio:float=2.0,noise_std:float=0.0,seed:int=0,anti_windup:bool=True,)->list[HoverSample]:"""Fly from z = 0 to ``setpoint`` and return the trajectory. Plant: (mass + payload) * z'' = T - (mass + payload) * g - drag * z'. The controller believes the vehicle weighs ``feedforward_mass`` and adds that hover thrust as feedforward (None means no feedforward). Thrust is limited to [0, max_thrust_ratio * mass * g]; motors cannot pull down. The altimeter adds Gaussian noise with standard deviation ``noise_std``. """ifdt<=0.0orduration<=0.0:raiseValueError("dt and duration must be positive")true_mass=mass+payloadt_max=max_thrust_ratio*mass*Gff=0.0iffeedforward_massisNoneelsefeedforward_mass*Gpid=PID(kp,ki,kd,out_min=-ff,out_max=t_max-ff,anti_windup=anti_windup)rng=random.Random(seed)z=vz=0.0samples:list[HoverSample]=[]steps=round(duration/dt)forkinrange(steps+1):t=k*dtmeasured=z+(rng.gauss(0.0,noise_std)ifnoise_std>0.0else0.0)thrust=ff+pid.update(setpoint,measured,dt)samples.append(HoverSample(t,z,vz,thrust))# Semi-implicit Euler: update velocity first, then position.accel=(thrust-true_mass*G-drag*vz)/true_massvz+=accel*dtz+=vz*dtreturnsamples@dataclass(frozen=True)classStepMetrics:overshoot_pct:float# peak above target as % of the step sizerise_time:float|None# 10% -> 90% of the step, ssettling_time:float|None# last entry into the +/-2% band, ssteady_state_error:float# target - mean altitude over the final second, mdefstep_metrics(samples:list[HoverSample],setpoint:float,start:float=0.0)->StepMetrics:step=setpoint-startifstep==0.0:raiseValueError("setpoint must differ from start")peak=max(s.zforsinsamples)overshoot=max(0.0,(peak-setpoint)/abs(step)*100.0)t10=next((s.tforsinsamplesif(s.z-start)>=0.1*step),None)t90=next((s.tforsinsamplesif(s.z-start)>=0.9*step),None)rise=Noneift10isNoneort90isNoneelset90-t10band=0.02*abs(step)settling=Noneforiinrange(len(samples)-1,-1,-1):ifabs(samples[i].z-setpoint)>band:settling=samples[i+1].tifi+1<len(samples)elseNonebreakelse:settling=samples[0].tlast_t=samples[-1].ttail=[s.zforsinsamplesifs.t>=last_t-1.0]sse=setpoint-sum(tail)/len(tail)returnStepMetrics(overshoot,rise,settling,sse)deffinite_difference(values:list[float],dt:float)->list[float]:"""Backward-difference derivative: (v[k] - v[k-1]) / dt, first entry 0."""return[0.0]+[(values[k]-values[k-1])/dtforkinrange(1,len(values))]# ---------------------------------------------------------------------------# Car path following: kinematic bicycle model + pure pursuit# ---------------------------------------------------------------------------@dataclass(frozen=True)classCarState:x:float# rear-axle position, my:floatyaw:float# heading, radsteer:float# front-wheel angle, raddefbicycle_step(state:CarState,speed:float,steer:float,wheelbase:float,dt:float)->CarState:"""Advance the kinematic bicycle model (reference point: rear axle)."""x=state.x+speed*math.cos(state.yaw)*dty=state.y+speed*math.sin(state.yaw)*dtyaw=state.yaw+speed/wheelbase*math.tan(steer)*dtreturnCarState(x,y,yaw,steer)defpure_pursuit_steer(state:CarState,target:tuple[float,float],wheelbase:float)->float:"""Steering angle that puts the rear axle on a circle through ``target``. alpha is the angle from the heading to the target; ld is the distance to it. Curvature kappa = 2 sin(alpha) / ld, and the bicycle model needs tan(delta) = wheelbase * kappa. """dx,dy=target[0]-state.x,target[1]-state.yld=math.hypot(dx,dy)ifld==0.0:return0.0alpha=math.atan2(dy,dx)-state.yawkappa=2.0*math.sin(alpha)/ldreturnmath.atan(wheelbase*kappa)def_closest_on_segment(p,a,b):ax,ay=abx,by=bvx,vy=bx-ax,by-ayseg2=vx*vx+vy*vyu=0.0ifseg2==0.0elsemax(0.0,min(1.0,((p[0]-ax)*vx+(p[1]-ay)*vy)/seg2))cx,cy=ax+u*vx,ay+u*vyreturn(cx,cy),math.hypot(p[0]-cx,p[1]-cy),udefcross_track_error(point:tuple[float,float],path:list[tuple[float,float]])->float:"""Unsigned distance from ``point`` to the polyline ``path``."""returnmin(_closest_on_segment(point,path[i],path[i+1])[1]foriinrange(len(path)-1))deflookahead_point(point,path,lookahead:float,start_index:int=0):"""First point on the path, at or after the closest point, that is ``lookahead`` away. Returns (target, closest_segment_index). Falls back to the final path point when the path ends inside the look-ahead circle. The closest-point search only looks a bounded distance ahead of the previous index. A global search breaks on paths that revisit the same place (a loop, a figure eight): the "closest" segment can belong to a later lap, and the car then chases a point it has not reached yet. """window=2.0*lookahead+5.0best_i,best_d,best_u=start_index,math.inf,0.0travelled=0.0foriinrange(start_index,len(path)-1):_,d,u=_closest_on_segment(point,path[i],path[i+1])ifd<best_d:best_i,best_d,best_u=i,d,utravelled+=math.dist(path[i],path[i+1])iftravelled>window:breakforiinrange(best_i,len(path)-1):a,b=path[i],path[i+1]u0=best_uifi==best_ielse0.0# Solve |a + u (b - a) - p| = lookahead for u in [u0, 1] (largest root).vx,vy=b[0]-a[0],b[1]-a[1]fx,fy=a[0]-point[0],a[1]-point[1]qa=vx*vx+vy*vyqb=2.0*(fx*vx+fy*vy)qc=fx*fx+fy*fy-lookahead*lookaheaddisc=qb*qb-4.0*qa*qcifqa==0.0ordisc<0.0:continueu=(-qb+math.sqrt(disc))/(2.0*qa)ifu0<=u<=1.0:return(a[0]+u*vx,a[1]+u*vy),best_ireturnpath[-1],best_idefsimulate_pure_pursuit(path:list[tuple[float,float]],*,start:CarState,speed:float=10.0,lookahead:float=8.0,wheelbase:float=2.9,max_steer:float=math.radians(35.0),max_steer_rate:float|None=None,dt:float=0.02,duration:float=20.0,)->tuple[list[CarState],list[float]]:"""Drive along ``path``; return the states and cross-track errors per tick. ``max_steer_rate`` (rad/s) models a steering actuator that cannot turn the wheels instantly. None means an ideal, instantaneous actuator. """iflookahead<=0.0ordt<=0.0:raiseValueError("lookahead and dt must be positive")state=startstates,errors=[state],[cross_track_error((state.x,state.y),path)]index=0for_inrange(round(duration/dt)):target,index=lookahead_point((state.x,state.y),path,lookahead,index)steer=max(-max_steer,min(max_steer,pure_pursuit_steer(state,target,wheelbase)))ifmax_steer_rateisnotNone:step=max_steer_rate*dtsteer=state.steer+max(-step,min(step,steer-state.steer))state=bicycle_step(state,speed,steer,wheelbase,dt)states.append(state)errors.append(cross_track_error((state.x,state.y),path))returnstates,errorsdefstraight_path(length:float=400.0,step:float=1.0)->list[tuple[float,float]]:n=round(length/step)return[(i*step,0.0)foriinrange(n+1)]defcircle_path(radius:float,turns:float=1.0,points:int=720)->list[tuple[float,float]]:"""Counter-clockwise circle centred at (0, radius), starting at the origin heading +x."""total=2.0*math.pi*turnsreturn[(radius*math.sin(total*k/points),radius-radius*math.cos(total*k/points))forkinrange(points+1)]deflane_change_path(length:float=200.0,shift:float=3.5,start:float=60.0,width:float=40.0,step:float=0.5)->list[tuple[float,float]]:"""Straight road with a smooth (cosine) lane change of ``shift`` metres."""pts=[]n=round(length/step)foriinrange(n+1):x=i*stepifx<=start:y=0.0elifx>=start+width:y=shiftelse:y=shift*0.5*(1.0-math.cos(math.pi*(x-start)/width))pts.append((x,y))returnpts