importosimportnumpyasnpfromscipyimportlinalgimportmatplotlib.pyplotaspltfromdatetimeimportdatetimeimporttimefromremoveredundantconimportremoveredundantconfromfindinteriorpointimportfindinteriorpointfromhalfspace2verteximporthalfspace2vertex# Module-level log file (persistent state across calls)._logfile=[None]deflog_(msg=None,*args):"""Write to log. Pass a file object to open it; call with no args to close."""ifhasattr(msg,'write'):_logfile[0]=msgelifmsgisNone:if_logfile[0]isnotNone:_logfile[0].close()_logfile[0]=Noneelif_logfile[0]isnotNone:_logfile[0].write(msg%argsifargselsemsg)_logfile[0].flush()defviridiscolors(n):"""Returns n colors from the viridis colormap as an (n, 3) RGB array."""cmap=plt.cm.get_cmap('viridis',n)returnnp.array([cmap(i)[:3]foriinrange(n)])defbin2hex(bits):"""Converts a boolean vector to a hexadecimal string."""hexchars='0123456789ABCDEF'bits=np.asarray(bits,dtype=float).flatten()Npad=(-len(bits))%4bits=np.concatenate([np.zeros(Npad),bits])Nhex=len(bits)//4digits=np.array([8,4,2,1])@bits.reshape(4,Nhex)return''.join(hexchars[int(d)]fordindigits)defgetcrid(active):"""Returns the string ID of the given set of active constraints."""return'CR_'+bin2hex(active)defscalerows(A,b,zerotol=1e-12):"""Scales rows of [A, b] to unit norm; zeros trivial rows."""b=np.asarray(b,dtype=float).flatten()Z=np.hstack([A,b[:,None]])scale=np.sqrt(np.sum(Z**2,axis=1))trivrows=(scale==0)scale[trivrows]=1.0A=A/scale[:,None]b=b/scaleb[trivrows]=1.0A[np.abs(A)<zerotol]=0.0returnA,b,scaledefcheckcrfullrank(dmpqp,activecon):"""Checks whether the active-constraint submatrix of H is positive definite."""i=activeconHii=dmpqp['H'][np.ix_(np.where(i)[0],np.where(i)[0])]ifHii.size==0:returnTrue,Hiitry:Hiihalf=linalg.cholesky(Hii,lower=False)# upper triangularfullrank=bool(np.all(np.diag(Hiihalf)>1e-6))exceptlinalg.LinAlgError:Hiihalf=np.zeros_like(Hii)fullrank=Falsereturnfullrank,Hiihalfdefgetcriticalregion(pmpqp,dmpqp,crset):"""Returns the critical region and optimal control law for a given active set."""activecon=crset['active']if'Hiihalf'incrset:Hiihalf=crset['Hiihalf']okay=Trueelse:okay,Hiihalf=checkcrfullrank(dmpqp,activecon)cr={}ifnotokay:returncr,Falsei=activeconj=~activeconi_idx=np.where(i)[0]j_idx=np.where(j)[0]# Solve for dual variable as affine function of parameters:# Y * [p; 1] = lambda*(p), using Hiihalf (upper Cholesky of H[i,i]).RHS=np.column_stack([dmpqp['D'][i_idx,:],dmpqp['d'][i_idx]])Y=-linalg.cho_solve((Hiihalf,False),RHS)# (Ni, Np+1)# Complementary slackness for inactive constraints.W=dmpqp['H'][np.ix_(j_idx,i_idx)]@Y+ \
np.column_stack([dmpqp['D'][j_idx,:],dmpqp['d'][j_idx]])# (Nj, Np+1)P=np.vstack([W,Y])# (Nj+Ni, Np+1)A=-P[:,:-1]# critical region: A*p <= bb=P[:,-1]# Screen out near-zero rows.Anorms=np.max(np.abs(A),axis=1)badrows=(Anorms<1e-10)ifnp.any(b[badrows]<0):returncr,FalseA[badrows,:]=0.0b[badrows]=1.0# Append parameter bounding region.Ne=len(pmpqp['e'])A=np.vstack([A,pmpqp['E']])b=np.concatenate([b,pmpqp['e']])A,b,_=scalerows(A,b)x,okay,_,_=findinteriorpoint(A,b)ifnotokay:returncr,Falsecr['A']=Acr['b']=bcr['cind']=np.concatenate([j_idx,i_idx,-np.arange(1,Ne+1)]).astype(int)cr['ctype']=np.array(['w']*len(j_idx)+['y']*len(i_idx)+['e']*Ne)cr['x']=np.asarray(x).flatten()# Optimal control law: z*(p) = Z*p + z0.G_i=pmpqp['G'][i_idx,:]inner=G_i.T@Y+np.column_stack([pmpqp['F'],pmpqp['c']])# (Nu, Np+1)law=-linalg.cho_solve((pmpqp['Qhalf'],False),inner)# (Nu, Np+1)cr['Z']=law[:,:-1]cr['z0']=law[:,-1]returncr,Truedefsolveprimalqp(x,pmpqp):"""Solves the primal QP at a given parameter value x."""fromscipy.optimizeimportminimizeQ=pmpqp['Q']f=(pmpqp['F']@np.asarray(x).flatten()).flatten()A_ineq=pmpqp['G']b_ineq=(pmpqp['S']@np.asarray(x).flatten()+pmpqp['W']).flatten()n=Q.shape[0]result=minimize(fun=lambdaz:0.5*z@Q@z+f@z,jac=lambdaz:Q@z+f,x0=np.zeros(n),method='SLSQP',constraints={'type':'ineq','fun':lambdaz:b_ineq-A_ineq@z,'jac':lambdaz:-A_ineq},options={'ftol':1e-10,'maxiter':500,'disp':False},)z=result.xifresult.successelsenp.full(n,np.nan)feas=result.successslack=A_ineq@z-b_ineqactive=np.abs(slack)<1e-5returnz,feas,slack,activedefaddcr(opensets,nopen,newcr):"""Adds a new candidate critical region to the open set."""nopen+=1opensets[f'N{nopen}']=newcrlog_(f" Adding {newcr['id']}\n")returnopensets,nopendeffindadjacentcr(cr,pmpqp):"""Finds an adjacent CR by stepping across the given facet and solving the QP."""ineq=np.ones(cr['A'].shape[0],dtype=bool)ineq[cr['facet']]=FalseAeq=cr['A'][[cr['facet']],:]beq=cr['b'][[cr['facet']]]x0,okay,_,_=findinteriorpoint(cr['A'][ineq,:],cr['b'][ineq],Aeq,beq)ifokay:normal=cr['A'][cr['facet'],:]x0p=np.asarray(x0).flatten()+1e-4*normal/np.linalg.norm(normal)_,feas,_,newactive=solveprimalqp(x0p,pmpqp)else:feas=Falsenewactive=np.array([],dtype=bool)ifnotokayornotfeas:newactive=np.array([],dtype=bool)returnnewactive,feasdefsolvempqp(prob,**kwargs):""" Solves the multiparametric QP: min 0.5*z'*Q*z + p'*C'*z z s.t. A*z <= b + S*p for all p satisfying E*p <= e. prob must contain Q, C, A, b, S. E and e are optional. """defaults={'display':True,'logfile':'','vertices':np.nan,'plot':False,'maxiter':np.inf,'ascell':True,'maxtime':np.inf,'printevery':100,}options={**defaults,**kwargs}ifoptions['logfile']:log_(open(options['logfile'],'w'))log_(f"Log started {datetime.now()}\n")# Convert to Bemporad (2015) nomenclature.pmpqp={}pmpqp['G']=np.asarray(prob['A'])pmpqp['W']=np.asarray(prob['b']).flatten()pmpqp['S']=np.asarray(prob['S'])pmpqp['Q']=np.asarray(prob['Q'])pmpqp['Q']=0.5*(pmpqp['Q']+pmpqp['Q'].T)pmpqp['F']=np.asarray(prob['C'])pmpqp['c']=np.zeros(pmpqp['F'].shape[0])pmpqp['Qhalf']=linalg.cholesky(pmpqp['Q'],lower=False)# upper triangularpmpqp['E']=np.asarray(prob.get('E',np.zeros((0,pmpqp['S'].shape[1]))))ifpmpqp['E'].size==0:pmpqp['e']=np.zeros(0)else:if'e'notinprob:raiseValueError('prob["e"] must be provided if prob["E"] is given!')pmpqp['e']=np.asarray(prob['e']).flatten()ifnp.isnan(options['vertices']):options['vertices']=(pmpqp['S'].shape[1]==2)# Dual problem matrices.Qinv_G=linalg.cho_solve((pmpqp['Qhalf'],False),pmpqp['G'].T)# Q^{-1} G'dmpqp={}dmpqp['H']=pmpqp['G']@Qinv_Gdmpqp['D']=pmpqp['G']@linalg.cho_solve((pmpqp['Qhalf'],False),pmpqp['F'])+pmpqp['S']dmpqp['d']=pmpqp['G']@linalg.cho_solve((pmpqp['Qhalf'],False),pmpqp['c'])+pmpqp['W']# qhull options (passed as dict to ConvexHull via removeredundantcon).qhullstr='Qt QbB Pp'ifpmpqp['S'].shape[1]>=5:qhullstr+=' Qx'qhullopts={'qhull_options':qhullstr}# Initialise: no constraints active at the origin.Ncon=dmpqp['D'].shape[0]init_active=np.zeros(Ncon,dtype=bool)opensets={'N1':{'active':init_active,'id':getcrid(init_active)}}nopen=1nfound=0regions={}probedregions={opensets['N1']['id']:[]}niter=0starttime=time.time()maxtime=options['maxtime']+starttimewhilenopen>0andniter<options['maxiter']andtime.time()<maxtime:niter+=1ifoptions['display']andniter%options['printevery']==0:print(f"Iteration {niter} ({nfound} found, {nopen} left to search).")log_(f"Iteration {niter}\n")activestr=f'N{nopen}'nopen-=1currentset=opensets.pop(activestr)crid=currentset['id']log_(f" Getting {crid}\n")cr,okay=getcriticalregion(pmpqp,dmpqp,currentset)ifokay:log_(" Found representation\n")nonredundant,cr['A'],cr['b'],_,_=removeredundantcon(cr['A'],cr['b'],cr['x'],None,qhullopts)cr['cind']=cr['cind'][nonredundant]cr['ctype']=cr['ctype'][nonredundant]ifoptions['vertices']:cr['V'],_=halfspace2vertex(cr['A'],cr['b'],cr['x'])regions[crid]=crnfound+=1log_(" Searching for adjacent regions\n")forkinrange(len(nonredundant)):n=cr['cind'][k]newactive=currentset['active'].copy()tryqpsearch=Trueifcr['ctype'][k]=='w':newactive[n]=Trueelifcr['ctype'][k]=='y':newactive[n]=Falsetryqpsearch=Falseelse:continuekeepgoing=Truewhilekeepgoing:newcrid=getcrid(newactive)ifnewcridnotinprobedregions:probedregions[newcrid]=[]newfullrank,newHiihalf=checkcrfullrank(dmpqp,newactive)ifnewfullrank:newcr={'active':newactive,'id':newcrid,'Hiihalf':newHiihalf}opensets,nopen=addcr(opensets,nopen,newcr)keepgoing=Falseelse:log_(f" {newcrid} is not full-rank\n")iftryqpsearch:tryqpsearch=Falselog_(" Trying QP neighbor search\n")cr['facet']=knewactive,keepgoing=findadjacentcr(cr,pmpqp)ifnotkeepgoing:log_(" No neighbor found\n")else:keepgoing=Falseelse:keepgoing=Falseelse:log_(f" {crid} is infeasible\n")log_(f" {nopen} regions open\n")log_(f"Finished with {nopen} open regions\n")log_()ifoptions['display']:elapsed=time.time()-starttimeprint(f"Found {nfound} regions ({nopen} left to search) after "f"{niter} iterations (in {elapsed:.1f} s).")# Optional plot.ifoptions['plot']andpmpqp['S'].shape[1]==2andnotos.getenv('OMIT_PLOTS')=='true':print("Making plot (may take some time).")plt.figure()fields=list(regions.keys())colors=viridiscolors(len(fields))cperm=np.argsort(np.sin(np.arange(1,len(fields)+1)))colors=colors[cperm]fori,finenumerate(fields):plt.fill(regions[f]['V'][:,0],regions[f]['V'][:,1],color=colors[i])plt.xlabel('p_1')plt.ylabel('p_2',rotation=0)# Return as list (mimicking MATLAB cell array).ifoptions['ascell']:result=[]forf,rinregions.items():region=r.copy()region['id']=fresult.append(region)returnresultreturnregions
# Example explicit MPC for a 2D system From "Optimal control of constrained# piecewise affine discrete-time systems" (Mayne and Rakovic, 2002).# [depends] functions/solvempqp.py# [makes] explicitmpc2d.matimportosimportsysimportnumpyasnpfromscipy.linalgimportsolve_discrete_arefromscipy.ioimportsavematsys.path.append('functions/')fromcalcOinfimportcalcOinffromss2mpqpimportss2mpqpfromsolvempqpimportsolvempqp,viridiscolors# Choose system.A=np.array([[1.,0.1],[0.,1.]])B=np.array([[0.],[0.0787]])Q=np.array([[1.,0.],[0.,0.]])R=np.array([[0.1]])ulb=np.array([-1.])uub=np.array([1.])N=10# Find LQR terminal cost and feedback gain.P_are=solve_discrete_are(A,B,Q,R)K_lqr=np.linalg.solve(R+B.T@P_are@B,B.T@P_are@A)K=-K_lqr# u = K*x (negative gain convention)# Find terminal set Oinf for x^+ = (A + B*K)*x subject to |K*x| <= 1.A_u=np.vstack([-K,K])b_u=np.array([-float(ulb[0]),float(uub[0])])A_lqr,b_lqr,_=calcOinf(A+B@K,A_u,b_u)b_lqr=np.asarray(b_lqr).flatten()# Build mpQP and solve.prob={'A':A,'B':B,'Q':Q,'R':R,'P':P_are,'N':N,'ulb':ulb,'uub':uub,'Af':A_lqr,'bf':b_lqr}mpqp,_=ss2mpqp(prob)regions=solvempqp(mpqp,plot=True)