np.ndarray:
437 | '''
438 | RCAM model implementation
439 | sources: RCAM docs and Christopher Lum
440 | Group for Aeronautical Research and Technology Europe (GARTEUR) - Research Civil Aircraft Model (RCAM)
441 | http://garteur.org/wp-content/reports/FM/FM_AG-08_TP-088-3.pdf
442 |
443 | Christopher Lum - Equations/Modeling
444 | https://www.youtube.com/watch?v=bFFAL9lI2IQ
445 | Christopher Lum - Matlab implementation
446 | https://www.youtube.com/watch?v=m5sEln5bWuM
447 |
448 | inputs:
449 | X: states
450 | 0: u (m/s)
451 | 1: v (m/s)
452 | 2: w (m/s)
453 | 3: p (rad/s)
454 | 4: q (rad/s)
455 | 5: r (rad/s)
456 | 6: phi (rad)
457 | 7: theta (rad)
458 | 8: psi (rad)
459 | U: controls
460 | 0: aileron (rad)
461 | 1: stabilator (rad)
462 | 2: rudder (rad)
463 | 3: throttle 1 (rad)
464 | 4: throttle 2 (rad)
465 | rho: density for current altitude
466 | outputs:
467 | X_dot: derivatives of states (same order)
468 | '''
469 |
470 | #------------------------ constants -------------------------------
471 |
472 | # Nominal vehicle constants
473 | m = 120000; # kg - total mass
474 |
475 | cbar = 6.6 # m - mean aerodynamic chord
476 | lt = 24.8 # m - tail AC distance to CG
477 | S = 260 # m2 - wing area
478 | St = 64 # m2 - tail area
479 |
480 | # centre of gravity position
481 | Xcg = 0.23 * cbar # m - x pos of CG in Fm
482 | Ycg = 0.0 # m - y pos of CG in Fm
483 | Zcg = 0.10 * cbar # m - z pos of CG in Fm ERRATA - table 2.4 has 0.0 and table 2.5 has 0.10 cbar
484 |
485 | # aerodynamic center position
486 | Xac = 0.12 * cbar # m - x pos of aerodynamic center in Fm
487 | Yac = 0.0 # m - y pos of aerodynamic center in Fm
488 | Zac = 0.0 # m - z pos of aerodynamic center in Fm
489 |
490 | # engine point of thrust aplication
491 | Xapt1 = 0 # m - x position of engine 1 in Fm
492 | Yapt1 = -7.94 # m - y position of engine 1 in Fm
493 | Zapt1 = -1.9 # m - z position of engine 1 in Fm
494 |
495 | Xapt2 = 0 # m - x position of engine 2 in Fm
496 | Yapt2 = 7.94 # m - y position of engine 2 in Fm
497 | Zapt2 = -1.9 # m - z position of engine 2 in Fm
498 |
499 | # other constants
500 | #rho = 1.225 # kg/m3 - air density
501 | g = 9.81 # m/s2 - gravity
502 | depsda = 0.25 # rad/rad - change in downwash wrt alpha
503 | deg2rad = np.pi / 180 # from degrees to radians
504 | alpha_L0 = -11.5 * deg2rad # rad - zero lift AOA
505 | n = 5.5 # adm - slope of linear ragion of lift slope
506 | a3 = -768.5 # adm - coeff of alpha^3
507 | a2 = 609.2 # adm - - coeff of alpha^2
508 | a1 = -155.2 # adm - - coeff of alpha^1
509 | a0 = 15.212 # adm - - coeff of alpha^0 ERRATA RCAM has 15.2
510 | alpha_switch = 14.5 * deg2rad # rad - kink point of lift slope
511 |
512 |
513 | #----------------- intermediate variables ---------------------------
514 | # airspeed
515 | Va = np.sqrt(X[0]**2 + X[1]**2 + X[2]**2) # m/s
516 |
517 | # alpha and beta
518 | #np.arctan2 --> y, x
519 | alpha = np.arctan2(X[2], X[0])
520 | beta = np.arcsin(X[1]/Va)
521 |
522 | # dynamic pressure
523 | Q = 0.5 * rho * Va**2
524 |
525 | # define vectors wbe_b and V_b
526 | wbe_b = np.array([X[3], X[4], X[5]])
527 | V_b = np.array([X[0], X[1], X[2]])
528 |
529 | #----------------- aerodynamic force coefficients ---------------------
530 | # CL - wing + body
531 | CL_wb = n * (alpha - alpha_L0) if alpha <= alpha_switch else a3 * alpha**3 + a2 * alpha**2 + a1 * alpha + a0
532 |
533 | # CL thrust
534 | epsilon = depsda * (alpha - alpha_L0)
535 | alpha_t = alpha - epsilon + U[1] + 1.3 * X[4] * lt / Va
536 | CL_t = 3.1 * (St / S) * alpha_t
537 |
538 | # Total CL
539 | CL = CL_wb + CL_t
540 |
541 | # Total CD
542 | CD = 0.13 + 0.07 * (n * alpha + 0.654)**2
543 |
544 | # Total CY
545 | CY = -1.6 * beta + 0.24 * U[2]
546 |
547 |
548 | #------------------- dimensional aerodynamic forces --------------------
549 | # forces in F_s
550 | FA_s = np.array([-CD * Q* S, CY * Q * S, -CL * Q * S])
551 |
552 | # rotate forces to body axis (F_b)
553 | C_bs = np.array([[np.cos(alpha), 0.0, -np.sin(alpha)],
554 | [0.0, 1.0, 0.0],
555 | [np.sin(alpha), 0.0, np.cos(alpha)]], dtype=np.dtype('f8'))
556 |
557 | FA_b = np.dot(C_bs, FA_s)
558 |
559 |
560 | #------------------ aerodynamic moment coefficients about AC -----------
561 | # moments in F_b
562 | eta11 = -1.4 * beta
563 | eta21 = -0.59 - (3.1 * (St * lt) / (S * cbar)) * (alpha - epsilon)
564 | eta31 = (1 - alpha * (180 / (15 * np.pi))) * beta
565 |
566 | eta = np.array([eta11, eta21, eta31])
567 |
568 | dCMdx = (cbar / Va) * np.array([[-11.0, 0.0, 5.0],
569 | [ 0.0, (-4.03 * (St * lt**2) / (S * cbar**2)), 0.0],
570 | [1.7, 0.0, -11.5]], dtype=np.dtype('f8'))
571 | dCMdu = np.array([[-0.6, 0.0, 0.22],
572 | [0.0, (-3.1 * (St * lt) / (S * cbar)), 0.0],
573 | [0.0, 0.0, -0.63]], dtype=np.dtype('f8'))
574 |
575 |
576 | # CM about AC in Fb
577 | CMac_b = eta + np.dot(dCMdx, wbe_b) + np.dot(dCMdu, np.array([U[0], U[1], U[2]]))
578 |
579 | #------------------- aerodynamic moment about AC -------------------------
580 | # normalize to aerodynamic moment
581 | MAac_b = CMac_b * Q * S * cbar
582 |
583 | #-------------------- aerodynamic moment about CG ------------------------
584 | rcg_b = np.array([Xcg, Ycg, Zcg])
585 | rac_b = np.array([Xac, Yac, Zac])
586 |
587 | MAcg_b = MAac_b + np.cross(FA_b, rcg_b - rac_b)
588 |
589 | #---------------------- engine force and moment --------------------------
590 | # thrust
591 | F1 = U[3] * m * g
592 | F2 = U[4] * m * g
593 |
594 | #thrust vectors (assuming aligned with x axis)
595 | FE1_b = np.array([F1, 0, 0])
596 | FE2_b = np.array([F2, 0, 0])
597 |
598 | FE_b = FE1_b + FE2_b
599 |
600 | # engine moments
601 | mew1 = np.array([Xcg - Xapt1, Yapt1 - Ycg, Zcg - Zapt1])
602 | mew2 = np.array([Xcg - Xapt2, Yapt2 - Ycg, Zcg - Zapt2])
603 |
604 | MEcg1_b = np.cross(mew1, FE1_b)
605 | MEcg2_b = np.cross(mew2, FE2_b)
606 |
607 | MEcg_b = MEcg1_b + MEcg2_b
608 |
609 | #---------------------- gravity effects ----------------------------------
610 | g_b = np.array([-g * np.sin(X[7]), g * np.cos(X[7]) * np.sin(X[6]), g * np.cos(X[7]) * np.cos(X[6])])
611 |
612 | Fg_b = m * g_b
613 |
614 | #---------------------- state derivatives --------------------------------
615 | # inertia tensor
616 | Ib = m * np.array([[40.07, 0.0, -2.0923],
617 | [0.0, 64.0, 0.0],
618 | [-2.0923, 0.0, 99.92]], dtype=np.dtype('f8')) # ERRATA on Ixz p. 12 vs p. 91
619 | invIb = np.linalg.inv(Ib)
620 |
621 | # form F_b and calculate u, v, w dot
622 | F_b = Fg_b + FE_b + FA_b
623 |
624 | x0x1x2_dot = (1 / m) * F_b - np.cross(wbe_b, V_b)
625 |
626 | # form Mcg_b and calc p, q r dot
627 | Mcg_b = MAcg_b + MEcg_b
628 |
629 | x3x4x5_dot = np.dot(invIb, (Mcg_b - np.cross(wbe_b, np.dot(Ib , wbe_b))))
630 |
631 | #phi, theta, psi dot
632 | H_phi = np.array([[1.0, np.sin(X[6]) * np.tan(X[7]), np.cos(X[6]) * np.tan(X[7])],
633 | [0.0, np.cos(X[6]), -np.sin(X[6])],
634 | [0.0, np.sin(X[6]) / np.cos(X[7]), np.cos(X[6]) / np.cos(X[7])]], dtype=np.dtype('f8'))
635 |
636 | x6x7x8_dot = np.dot(H_phi, wbe_b)
637 |
638 | #--------------------- place in first order form --------------------------
639 | X_dot = np.concatenate((x0x1x2_dot, x3x4x5_dot, x6x7x8_dot))
640 |
641 | return X_dot
642 |
643 |
644 | # Navigation Equations
645 | # source:
646 | # Christopher Lum - "The Naviation Equations: Computing Position North, East and Down"
647 | # https://www.youtube.com/watch?v=XQZV-YZ7asE
648 |
649 |
650 | @jit
651 | def NED(uvw, phithetapsi):
652 | '''
653 | compute the NED velocities from:
654 | inputs
655 | uvw: array with u, v, w
656 | phithetapsi: array with phi, theta, psi
657 |
658 | returns
659 | velocities in NED
660 |
661 | remember that h_dot = -Vd
662 | '''
663 |
664 | u = uvw[0]
665 | v = uvw[1]
666 | w = uvw[2]
667 | phi = phithetapsi[0]
668 | the = phithetapsi[1]
669 | psi = phithetapsi[2]
670 | c1v = np.array([[np.cos(psi), np.sin(psi), 0.0],
671 | [-np.sin(psi), np.cos(psi), 0.0],
672 | [0.0, 0.0, 1.0]])
673 |
674 | c21 = np.array([[np.cos(the), 0.0, -np.sin(the)],
675 | [0.0, 1.0, 0.0],
676 | [np.sin(the), 0.0, np.cos(the)]])
677 |
678 | cb2 = np.array([[1.0, 0.0, 0.0],
679 | [0.0, np.cos(phi), np.sin(phi)],
680 | [0.0, -np.sin(phi), np.cos(phi)]])
681 |
682 | cbv = np.dot(cb2, np.dot(c21,c1v)) #numba does not support np.matmul
683 | return np.dot(cbv.T, uvw)
684 |
685 |
686 | # # # # # Model integration # # # # #
687 |
688 | # # # wrappers
689 | # Scipy's "integrate.ode" does not accept a numba/@jit compiled function
690 | # therefore, we need to create dummy wrappers
691 |
692 | def RCAM_model_wrapper(t, X, U, rho):
693 | return RCAM_model(X, U, rho)
694 |
695 | def NED_wrapper(t, X, NED):
696 | return NED
697 |
698 | def latlonh_dot_wrapper(t, X, V_NED, lat, h):
699 | return latlonh_dot(V_NED, lat, h)
700 |
701 |
702 | # # # integrators
703 | def ss_integrator(t_ini:float, X0:np.ndarray, U:np.ndarray, rho:float):
704 |
705 | '''
706 | single step integrator
707 | returns scipy object, initialized
708 | '''
709 |
710 | RK_integrator = integrate.ode(RCAM_model_wrapper)
711 | RK_integrator.set_integrator('dopri5')
712 | RK_integrator.set_f_params(control_sat(U), rho)
713 | RK_integrator.set_initial_value(X0, t_ini)
714 | return RK_integrator
715 |
716 | def time_span_int(t_ini:float, t_fin:float, dt:float, X0:np.ndarray, U:np.ndarray, rho:float) -> np.ndarray:
717 | '''
718 | function to integrate the model in a time span, with FIXED dt
719 |
720 | inputs:
721 | t_ini: initial time in seconds
722 | t-fin: final time in seconds
723 | dt: delta time between steps, in seconds
724 | X0: initial states
725 | U: controls positions
726 | outputs:
727 | t_vector: time vector
728 | result_array: states integrated for all time steps in time vector
729 | '''
730 |
731 | t_vector = np.arange(np.datetime64('2011-06-15T00:00'), np.datetime64('2011-06-15T00:00') + np.timedelta64(t_fin, 's'),np.timedelta64(int(dt*1000),'ms'), dtype='datetime64')
732 |
733 | RK_integrator = integrate.ode(RCAM_model_wrapper)
734 | RK_integrator.set_integrator('dopri5')
735 | RK_integrator.set_f_params(control_sat(U), rho)
736 | RK_integrator.set_initial_value(X0, t_ini)
737 | collector = []
738 |
739 | for _ in t_vector:
740 | RK_integrator.integrate(RK_integrator.t + dt)
741 | aux = np.insert(RK_integrator.y, 0, RK_integrator.t)
742 | collector.append(aux)
743 | result_array = np.array(collector)
744 | return(t_vector, result_array)
745 |
746 | def latlonh_int(t_ini:float, latlonh0:np.ndarray, V_NED):
747 |
748 | '''
749 | single step integrator for lat/long/height
750 | returns scipy object, initialized
751 | '''
752 |
753 | RK_integrator = integrate.ode(latlonh_dot_wrapper)
754 | RK_integrator.set_integrator('dopri5')
755 | RK_integrator.set_f_params(V_NED, latlonh0[0], latlonh0[2])
756 | RK_integrator.set_initial_value(latlonh0, t_ini)
757 | return RK_integrator
758 |
759 |
760 | # # Trimmer
761 | def trim_functional2(Z:np.ndarray, VA_trim, gamma_trim, v_trim, phi_trim, psi_trim, rho_trim) -> np.dtype('f8'):
762 | '''
763 | functional to calculate a cost for minimizer (used to find trim point)
764 | no constraints yet
765 | inputs:
766 | Z: lumped vector of X (states) and U (control)
767 | trim targets:
768 | VA_trim: airspeed [m/s]
769 | gamma_trim: climb gradient [rad]
770 | v-trim: side speed [m/s]
771 | phi_trim: roll angle [rad]
772 | psi_trim: course angle [rad]
773 |
774 | ****
775 | method
776 | Q.T*H*Q
777 | with H = diagonal matrix of "1"s (equal weights for all states)
778 |
779 | returns:
780 | cost [float]
781 | '''
782 |
783 | X = Z[:9]
784 | U = Z[9:]
785 |
786 | X_dot = RCAM_model(X, control_sat(U), rho_trim)
787 | V_NED_current = NED(X_dot[:3], X_dot[3:6])
788 |
789 | VA_current = VA(X[:3])
790 |
791 | gamma_current = X[7] - np.arctan2(X[2], X[0]) # only valid for wings level case
792 |
793 | Q = np.concatenate((X_dot, [VA_current - VA_trim], [gamma_current - gamma_trim], [X[1] - v_trim], [X[6] - phi_trim], [X[8] - psi_trim]))
794 | diag_ones = np.ones(Q.shape[0])
795 | H = np.diag(diag_ones)
796 |
797 | return np.dot(np.dot(Q.T, H), Q)
798 |
799 | def trim_model(VA_trim=85.0, gamma_trim=0.0, v_trim=0.0, phi_trim=0.0, psi_trim=0.0, rho_trim=1.225,
800 | X0=np.array([85, 0, 0, 0, 0, 0, 0, 0.1, 0]),
801 | U0=np.array([0, -0.1, 0, 0.08, 0.08])) -> np.ndarray:
802 | '''
803 | uses scipy minimize on functional to find trim point
804 | '''
805 |
806 | print(f'trimming with X0 = {X0}')
807 | print(f'trimming with U0 = {U0}')
808 | X0[0] = VA_trim
809 | Z0 = np.concatenate((X0, U0))
810 |
811 | result = minimize(trim_functional2, Z0, args=(VA_trim, gamma_trim, v_trim, phi_trim, psi_trim, rho_trim),
812 | method='L-BFGS-B', options={'disp':False, 'maxiter':5000,\
813 | 'gtol':1e-25, 'ftol':1e-25, \
814 | 'maxls':4000})
815 |
816 | return result.x, result.message
817 |
818 |
819 | # # Init
820 | def initialize(VA_t=85.0, gamma_t=0.0, latlon=np.zeros(2), altitude=10000, psi_t=0.0):
821 | '''
822 | this initializes the integrators at a straight and level flight condition
823 | inputs:
824 | VA_t: true airspeed at trim (m/s)
825 | gamma_t: flight path angle at trim (rad)
826 | latlon: initial lat and long (rad)
827 | altitude: trim altitude (ft)
828 | psi_t: initial heading (rad)
829 | outputs:
830 | AC_integrator: aircraft integrator object
831 | X0: initial states found at trim point
832 | U0: initial commands found at trim point
833 | latlonh_integrator: navigation equation scipy object integrator
834 | '''
835 | ft2m = 0.3048
836 | t0 = 0.0 #intial time for integrators
837 |
838 | print(f'initializing model with altitude {altitude} ft, rho={get_rho(altitude)}')
839 |
840 | alt_m = altitude * ft2m
841 | latlonh0 = np.array([latlon[0]*deg2rad, latlon[1]*deg2rad, alt_m])
842 |
843 | # trim model
844 | res4, res4_status = trim_model(VA_trim=VA_t, gamma_trim=gamma_t, v_trim=t0,
845 | phi_trim=0.0, psi_trim=psi_t*deg2rad, rho_trim=get_rho(altitude))
846 | print(res4_status)
847 | X0 = res4[:9]
848 | U0 = res4[9:]
849 | print(f'initial states: {X0}')
850 | print(f'initial inputs: {U0}')
851 |
852 | # initialize integrators
853 | AC_integrator = ss_integrator(t0, X0, U0, get_rho(altitude))
854 |
855 | NED0 = NED(X0[:3], X0[6:]) #uvw and phithetapsi
856 |
857 | latlonh_integrator = latlonh_int(t0, latlonh0, NED0)
858 |
859 | return AC_integrator, X0, U0, latlonh_integrator
860 |
861 |
862 |
863 | if __name__ == "__main__":
864 |
865 | deg2rad = np.pi / 180 # from degrees to radians
866 |
867 | # Network socket to communicate with FlightGear
868 | UDP_IP = "127.0.0.1"
869 | UDP_PORT = 5500
870 | UDP_IP2 = "192.168.0.163"
871 | UDP_PORT2 = 5501
872 | sock = socket.socket(socket.AF_INET, # Internet
873 | socket.SOCK_DGRAM) # UDP
874 | sock2 = socket.socket(socket.AF_INET, # Internet
875 | socket.SOCK_DGRAM) # UDP
876 |
877 | pygame.init() # automatically initializes joystick also
878 |
879 | joystick_count = pygame.joystick.get_count()
880 | if joystick_count == 0:
881 | print('connect joystick first')
882 | exit()
883 |
884 | print(f'found {joystick_count} joysticks connected.')
885 | this_joy = pygame.joystick.Joystick(0)
886 | print(f'{this_joy.get_name()}, axes={this_joy.get_numaxes()}')
887 |
888 | signals_header = ['u', 'v', 'w', 'p', 'q', 'r', 'phi', 'theta', 'psi', 'lat', 'lon', 'h', 'V_N', 'V_E', 'V_D', 'dA', 'dE', 'dR', 'dT1', 'dT2']
889 |
890 | ############################################################################
891 | # INITIAL CONDITIONS
892 |
893 | # Altitude
894 | init_alt = 2100 #ft
895 |
896 | # IAS
897 | v_trim = 140 * kt2ms
898 |
899 | # Gamma
900 | gamma_trim = 0.0 * deg2rad
901 |
902 | # Lat/Lon
903 | #init_latlon = np.array([37.6213, -122.3790]) #in degrees - the func initialize transforms to radians internally
904 | #init_latlon = np.array([-21.7632, -48.4051]) #in degrees - SBGP
905 | init_latlon = np.array([47.2548, 11.2963]) #in degrees - LOWI short final TFB
906 |
907 | # Heading
908 | init_psi = 82.0
909 |
910 | ############################################################################
911 |
912 | # instantiate FG comms object and initialize it
913 | my_fgFDM = fgFDM()
914 | my_fgFDM.set('latitude', init_latlon[0], units='degrees')
915 | my_fgFDM.set('longitude', init_latlon[1], units='degrees')
916 | my_fgFDM.set('altitude', init_alt, units='meters')
917 | my_fgFDM.set('agl', init_alt, units='meters')
918 | my_fgFDM.set('num_engines', 2)
919 | my_fgFDM.set('num_tanks', 1)
920 | my_fgFDM.set('num_wheels', 3)
921 | my_fgFDM.set('cur_time', int(time.perf_counter()), units='seconds')
922 |
923 | #----------------- control limits / saturation ---------------------
924 | U_limits = [(-25 * deg2rad, 25 * deg2rad),
925 | (-25 * deg2rad, 10 * deg2rad),
926 | (-30 * deg2rad, 30 * deg2rad),
927 | (0.5 * deg2rad, 10 * deg2rad),
928 | (0.5 * deg2rad, 10 * deg2rad)]
929 |
930 |
931 | #######################################################################################
932 | # SIMULATION OPTIONS
933 |
934 | # start time
935 | t0 = 0
936 | # total simulation time
937 | tf = 10 * 60 #minutes
938 | # simulation loop frame rate throttling
939 | simFR = 400 # (Hz)
940 | # frames per second to be sent out to FG
941 | fgFR = 60 # (Hz)
942 |
943 | wind_speed = np.array([0, 0, 0]) # (m/s), NED
944 | wind_stddev = np.array([1, 1, 0]) #
945 |
946 | #######################################################################################
947 |
948 | # initializations
949 | # data collectors
950 | collector = []
951 | t_vector_collector = []
952 | prev_uvw = np.array([0,0,0])
953 | current_uvw = np.array([0,0,0])
954 |
955 |
956 | # aircraft initialization (includes trimming)
957 | this_AC_int, X1, U1, this_latlonh_int = initialize(VA_t=v_trim, gamma_t=gamma_trim, latlon=init_latlon, altitude=init_alt, psi_t=init_psi)
958 | U_man = U1.copy()
959 |
960 |
961 | # frame variables
962 | current_alt = init_alt
963 | current_latlon = init_latlon
964 | frame_count = 0
965 |
966 | send_frame_trigger = False
967 | fg_time_adder = 0 # counts the time between integration steps to trigger sending out a frame to FlightGear
968 |
969 | fgdt = 1 / (fgFR + 1) # (s) fg frame period
970 |
971 | run_sim_loop = False
972 |
973 | simdt = 1 / simFR # (s) desired simulation time step
974 | sim_time_adder = 0 # counts the time between integration steps to trigger next simulation frame
975 | dt = 0 # actual integration time step
976 | prev_dt = dt
977 |
978 | grav_accel = 9.81 # m/s
979 |
980 | exit_signal = 0 # if joystick button #1 is pressed, ends simulation
981 |
982 |
983 | # main loop
984 |
985 | while this_AC_int.t <= tf and exit_signal == 0:
986 | # get clock
987 | start = time.perf_counter()
988 |
989 | if run_sim_loop:
990 |
991 |
992 | _ = pygame.event.get()
993 |
994 | # get density, inputs
995 | current_rho = get_rho(current_alt * m2ft)
996 | U_man, U1, exit_signal = get_joy_inputs(this_joy, U1, simFR)
997 | U_man = control_sat(U_man)
998 |
999 | # set current integration step commands, density and integrate aircraft states
1000 | prev_uvw = current_uvw
1001 | this_AC_int.set_f_params(U_man, current_rho)
1002 | this_AC_int.integrate(this_AC_int.t + dt)
1003 | current_uvw = this_AC_int.y[0:3]
1004 |
1005 | # integrate navigation equations
1006 | current_NED = NED(this_AC_int.y[:3], this_AC_int.y[6:])
1007 | this_wind = add_wind(wind_speed, wind_stddev)
1008 | this_latlonh_int.set_f_params(current_NED + this_wind, current_latlon[0], current_alt)
1009 | this_latlonh_int.integrate(this_latlonh_int.t + dt) #in radians
1010 |
1011 | # store current state and time vector
1012 | current_latlon = this_latlonh_int.y[0:2]
1013 | current_alt = this_latlonh_int.y[2]
1014 | collector.append(np.concatenate((this_AC_int.y, this_latlonh_int.y, current_NED + this_wind, U_man)))
1015 | t_vector_collector.append(this_AC_int.t)
1016 |
1017 | # check for FG frame trigger
1018 | if send_frame_trigger:
1019 | # it is easier to calculate body accelerations instead of reaching into the RCAM function
1020 | if dt == 0:
1021 | body_accels = (current_uvw - prev_uvw) / prev_dt
1022 | else:
1023 | body_accels = (current_uvw - prev_uvw) / dt
1024 | # add gravity
1025 | g_b = np.array([-grav_accel * np.sin(this_AC_int.y[7]),
1026 | grav_accel * np.cos(this_AC_int.y[7]) * np.sin(this_AC_int.y[6]),
1027 | grav_accel * np.cos(this_AC_int.y[7]) * np.cos(this_AC_int.y[6])])
1028 | body_accels = body_accels + g_b
1029 | body_accels[2] = -body_accels[2]
1030 |
1031 | set_FDM(my_fgFDM, this_AC_int.y,
1032 | control_norm(U_man, U_limits),
1033 | current_latlon,
1034 | current_alt,
1035 | body_accels)
1036 | my_pack = my_fgFDM.pack()
1037 | sock.sendto(my_pack, (UDP_IP, UDP_PORT))
1038 | sock2.sendto(my_pack, (UDP_IP2, UDP_PORT2))
1039 | send_frame_trigger = False
1040 |
1041 |
1042 | frame_count += 1
1043 | # DEBUG ONLY -
1044 | # print out stuff every so often
1045 | if (frame_count % 100) == 0:
1046 | #print(f'frame: {frame_count}, time: {this_AC_int.t:0.2f}, theta:{this_AC_int.y[7]:0.6f}, Elev:{this_joy.get_axis(1) * elev_factor}')
1047 | #print(f'frame: {frame_count}, time: {this_AC_int.t:0.2f}, lat:{current_latlon[0]:0.6f}, lon:{current_latlon[1]:0.6f}')
1048 | #print(f'time: {this_AC_int.t:0.2f}, N:{current_NED[0]:0.3f}, E:{current_NED[1]:0.3f}, D:{current_NED[2]:0.3f}')
1049 | print(f'time: {this_AC_int.t:0.1f}s, Vcas_2fg:{my_fgFDM.get("vcas"):0.1f}KCAS, elev={U1[1]:0.3f} ail={U1[0]:0.3f}, T1/T2={U1[3]:0.3f},{U1[4]:0.3f}')
1050 |
1051 |
1052 |
1053 | # reset integrator timestep counter
1054 | prev_dt = dt
1055 | dt = 0
1056 | run_sim_loop = False
1057 |
1058 | #check/set frame triggers
1059 | if fg_time_adder >= fgdt:
1060 | fg_time_adder = 0
1061 | dt = sim_time_adder
1062 | send_frame_trigger = True
1063 |
1064 | if sim_time_adder >= simdt:
1065 | dt = sim_time_adder
1066 | sim_time_adder = 0
1067 | run_sim_loop = True
1068 |
1069 |
1070 | end = time.perf_counter()
1071 | this_frame_dt = end - start
1072 | fg_time_adder += this_frame_dt
1073 | sim_time_adder += this_frame_dt
1074 |
1075 |
1076 | # save data to disk
1077 | save2disk('test_data.csv', x_data=np.array(t_vector_collector), y_data=np.array(collector), header=signals_header, skip=0)
1078 | #fig1 = make_plots(x_data=np.array(t_vector_collector), y_data=np.array(collector), header=signals_header, skip=1)
1079 | #plt.show()
1080 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # PSim-RCAM
2 | Python implementation of the non-linear 6DOF GARTEUR RCAM aircraft flight dynamics model.
3 |
4 | Group for Aeronautical Research and Technology Europe (GARTEUR) - Research Civil Aircraft Model (RCAM).
5 | http://garteur.org/wp-content/reports/FM/FM_AG-08_TP-088-3.pdf
6 |
7 | The excellent tutorials by Christopher Lum (for Matlab/Simulink) were used as guides:
8 |
9 | 1 - Equations/Modeling: https://www.youtube.com/watch?v=bFFAL9lI2IQ
10 |
11 | 2 - Matlab implementation: https://www.youtube.com/watch?v=m5sEln5bWuM
12 |
13 | The program runs the integration loop at a user defined frame-rate, adjusting the integration steps to the available computing cycles to render real-time data to FlightGear.
14 |
15 | Output is sent to FlightGear (FG), over UDP, at a user specified frame rate.
16 | The FG interface uses the class implemented by Andrew Tridgel:
17 |
18 | fgFDM: https://github.com/ArduPilot/pymavlink/blob/master/fgFDM.py
19 |
20 | Currently, the UDP address is set to the local machine.
21 |
22 | Run this program in one terminal and from a separate terminal, start FG with one of the following commands (depending on the aircraft addons installed):
23 |
24 | fgfs --airport=KSFO --runway=28R --aircraft=ufo --native-fdm=socket,in,60,,5500,udp --fdm=null
25 |
26 | fgfs --airport=KSFO --runway=28R --aircraft=Embraer170 --aircraft-dir=./FlightGear/Aircraft/E-jet-family/ --native-fdm=socket,in,60,,5500,udp --fdm=null
27 |
28 | fgfs --airport=KSFO --runway=28R --aircraft=757-200-RB211 --aircraft-dir=~/.fgfs/Aircraft/org.flightgear.fgaddon.stable_2020/Aircraft/757-200 --native-fdm=socket,in,60,,5500,udp --fdm=null
29 |
30 | fgfs --airport=KSFO --runway=28R --aircraft=757-200-RB211 --aircraft-dir=~/.fgfs/Aircraft/org.flightgear.fgaddon.stable_2020/Aircraft/757-200 --native-fdm=socket,in,60,,5500,udp --fdm=null --enable-hud --turbulence=0.5 --in-air --enable-rembrandt
31 |
32 | REQUIRES a joystick to work. Tested with Logitech USB Stick.
33 |
--------------------------------------------------------------------------------
/fgDFM.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # parse and construct FlightGear NET FDM packets
3 | # Andrew Tridgell, November 2011
4 | # released under GNU GPL version 2 or later
5 | # https://github.com/ArduPilot/pymavlink/blob/master/fgFDM.py
6 |
7 | #from builtins import range
8 | #from builtins import object
9 | import math
10 | import struct
11 |
12 | class fgFDMError(Exception):
13 | '''fgFDM error class'''
14 | def __init__(self, msg):
15 | Exception.__init__(self, msg)
16 | self.message = 'fgFDMError: ' + msg
17 |
18 | class fgFDMVariable(object):
19 | '''represent a single fgFDM variable'''
20 | def __init__(self, index, arraylength, units):
21 | self.index = index
22 | self.arraylength = arraylength
23 | self.units = units
24 |
25 | class fgFDMVariableList(object):
26 | '''represent a list of fgFDM variable'''
27 | def __init__(self):
28 | self.vars = {}
29 | self._nextidx = 0
30 |
31 | def add(self, varname, arraylength=1, units=None):
32 | self.vars[varname] = fgFDMVariable(self._nextidx, arraylength, units=units)
33 | self._nextidx += arraylength
34 |
35 | class fgFDM(object):
36 | '''a flightgear native FDM parser/generator'''
37 | def __init__(self):
38 | '''init a fgFDM object'''
39 | self.FG_NET_FDM_VERSION = 24
40 | self.pack_string = '>I 4x 3d 6f 11f 3f 2f I 4I 4f 4f 4f 4f 4f 4f 4f 4f 4f I 4f I 3I 3f 3f 3f I i f 10f'
41 | self.values = [0]*98
42 |
43 | self.FG_MAX_ENGINES = 4
44 | self.FG_MAX_WHEELS = 3
45 | self.FG_MAX_TANKS = 4
46 |
47 | # supported unit mappings
48 | self.unitmap = {
49 | ('radians', 'degrees') : math.degrees(1),
50 | ('rps', 'dps') : math.degrees(1),
51 | ('feet', 'meters') : 0.3048,
52 | ('fps', 'mps') : 0.3048,
53 | ('knots', 'mps') : 0.514444444,
54 | ('knots', 'fps') : 0.514444444/0.3048,
55 | ('fpss', 'mpss') : 0.3048,
56 | ('seconds', 'minutes') : 60,
57 | ('seconds', 'hours') : 3600,
58 | }
59 |
60 | # build a mapping between variable name and index in the values array
61 | # note that the order of this initialisation is critical - it must
62 | # match the wire structure
63 | self.mapping = fgFDMVariableList()
64 | self.mapping.add('version')
65 |
66 | # position
67 | self.mapping.add('longitude', units='radians') # geodetic (radians)
68 | self.mapping.add('latitude', units='radians') # geodetic (radians)
69 | self.mapping.add('altitude', units='meters') # above sea level (meters)
70 | self.mapping.add('agl', units='meters') # above ground level (meters)
71 |
72 | # attitude
73 | self.mapping.add('phi', units='radians') # roll (radians)
74 | self.mapping.add('theta', units='radians') # pitch (radians)
75 | self.mapping.add('psi', units='radians') # yaw or true heading (radians)
76 | self.mapping.add('alpha', units='radians') # angle of attack (radians)
77 | self.mapping.add('beta', units='radians') # side slip angle (radians)
78 |
79 | # Velocities
80 | self.mapping.add('phidot', units='rps') # roll rate (radians/sec)
81 | self.mapping.add('thetadot', units='rps') # pitch rate (radians/sec)
82 | self.mapping.add('psidot', units='rps') # yaw rate (radians/sec)
83 | self.mapping.add('vcas', units='fps') # calibrated airspeed
84 | self.mapping.add('climb_rate', units='fps') # feet per second
85 | self.mapping.add('v_north', units='fps') # north velocity in local/body frame, fps
86 | self.mapping.add('v_east', units='fps') # east velocity in local/body frame, fps
87 | self.mapping.add('v_down', units='fps') # down/vertical velocity in local/body frame, fps
88 | self.mapping.add('v_wind_body_north', units='fps') # north velocity in local/body frame
89 | self.mapping.add('v_wind_body_east', units='fps') # east velocity in local/body frame
90 | self.mapping.add('v_wind_body_down', units='fps') # down/vertical velocity in local/body
91 |
92 | # Accelerations
93 | self.mapping.add('A_X_pilot', units='fpss') # X accel in body frame ft/sec^2
94 | self.mapping.add('A_Y_pilot', units='fpss') # Y accel in body frame ft/sec^2
95 | self.mapping.add('A_Z_pilot', units='fpss') # Z accel in body frame ft/sec^2
96 |
97 | # Stall
98 | self.mapping.add('stall_warning') # 0.0 - 1.0 indicating the amount of stall
99 | self.mapping.add('slip_deg', units='degrees') # slip ball deflection
100 |
101 | # Engine status
102 | self.mapping.add('num_engines') # Number of valid engines
103 | self.mapping.add('eng_state', self.FG_MAX_ENGINES) # Engine state (off, cranking, running)
104 | self.mapping.add('rpm', self.FG_MAX_ENGINES) # Engine RPM rev/min
105 | self.mapping.add('fuel_flow', self.FG_MAX_ENGINES) # Fuel flow gallons/hr
106 | self.mapping.add('fuel_px', self.FG_MAX_ENGINES) # Fuel pressure psi
107 | self.mapping.add('egt', self.FG_MAX_ENGINES) # Exhuast gas temp deg F
108 | self.mapping.add('cht', self.FG_MAX_ENGINES) # Cylinder head temp deg F
109 | self.mapping.add('mp_osi', self.FG_MAX_ENGINES) # Manifold pressure
110 | self.mapping.add('tit', self.FG_MAX_ENGINES) # Turbine Inlet Temperature
111 | self.mapping.add('oil_temp', self.FG_MAX_ENGINES) # Oil temp deg F
112 | self.mapping.add('oil_px', self.FG_MAX_ENGINES) # Oil pressure psi
113 |
114 | # Consumables
115 | self.mapping.add('num_tanks') # Max number of fuel tanks
116 | self.mapping.add('fuel_quantity', self.FG_MAX_TANKS)
117 |
118 | # Gear status
119 | self.mapping.add('num_wheels')
120 | self.mapping.add('wow', self.FG_MAX_WHEELS)
121 | self.mapping.add('gear_pos', self.FG_MAX_WHEELS)
122 | self.mapping.add('gear_steer', self.FG_MAX_WHEELS)
123 | self.mapping.add('gear_compression', self.FG_MAX_WHEELS)
124 |
125 | # Environment
126 | self.mapping.add('cur_time', units='seconds') # current unix time
127 | self.mapping.add('warp', units='seconds') # offset in seconds to unix time
128 | self.mapping.add('visibility', units='meters') # visibility in meters (for env. effects)
129 |
130 | # Control surface positions (normalized values)
131 | self.mapping.add('elevator')
132 | self.mapping.add('elevator_trim_tab')
133 | self.mapping.add('left_flap')
134 | self.mapping.add('right_flap')
135 | self.mapping.add('left_aileron')
136 | self.mapping.add('right_aileron')
137 | self.mapping.add('rudder')
138 | self.mapping.add('nose_wheel')
139 | self.mapping.add('speedbrake')
140 | self.mapping.add('spoilers')
141 |
142 | self._packet_size = struct.calcsize(self.pack_string)
143 |
144 | self.set('version', self.FG_NET_FDM_VERSION)
145 |
146 | if len(self.values) != self.mapping._nextidx:
147 | raise fgFDMError('Invalid variable list in initialisation')
148 |
149 | def packet_size(self):
150 | '''return expected size of FG FDM packets'''
151 | return self._packet_size
152 |
153 | def convert(self, value, fromunits, tounits):
154 | '''convert a value from one set of units to another'''
155 | if fromunits == tounits:
156 | return value
157 | if (fromunits,tounits) in self.unitmap:
158 | return value * self.unitmap[(fromunits,tounits)]
159 | if (tounits,fromunits) in self.unitmap:
160 | return value / self.unitmap[(tounits,fromunits)]
161 | raise fgFDMError("unknown unit mapping (%s,%s)" % (fromunits, tounits))
162 |
163 |
164 | def units(self, varname):
165 | '''return the default units of a variable'''
166 | if not varname in self.mapping.vars:
167 | raise fgFDMError('Unknown variable %s' % varname)
168 | return self.mapping.vars[varname].units
169 |
170 |
171 | def variables(self):
172 | '''return a list of available variables'''
173 | return sorted(list(self.mapping.vars.keys()),
174 | key = lambda v : self.mapping.vars[v].index)
175 |
176 |
177 | def get(self, varname, idx=0, units=None):
178 | '''get a variable value'''
179 | if not varname in self.mapping.vars:
180 | raise fgFDMError('Unknown variable %s' % varname)
181 | if idx >= self.mapping.vars[varname].arraylength:
182 | raise fgFDMError('index of %s beyond end of array idx=%u arraylength=%u' % (
183 | varname, idx, self.mapping.vars[varname].arraylength))
184 | value = self.values[self.mapping.vars[varname].index + idx]
185 | if units:
186 | value = self.convert(value, self.mapping.vars[varname].units, units)
187 | return value
188 |
189 | def set(self, varname, value, idx=0, units=None):
190 | '''set a variable value'''
191 | if not varname in self.mapping.vars:
192 | raise fgFDMError('Unknown variable %s' % varname)
193 | if idx >= self.mapping.vars[varname].arraylength:
194 | raise fgFDMError('index of %s beyond end of array idx=%u arraylength=%u' % (
195 | varname, idx, self.mapping.vars[varname].arraylength))
196 | if units:
197 | value = self.convert(value, units, self.mapping.vars[varname].units)
198 | # avoid range errors when packing into 4 byte floats
199 | if math.isinf(value) or math.isnan(value) or math.fabs(value) > 3.4e38:
200 | value = 0
201 | self.values[self.mapping.vars[varname].index + idx] = value
202 |
203 | def parse(self, buf):
204 | '''parse a FD FDM buffer'''
205 | try:
206 | t = struct.unpack(self.pack_string, buf)
207 | except struct.error as msg:
208 | raise fgFDMError('unable to parse - %s' % msg)
209 | self.values = list(t)
210 |
211 | def pack(self):
212 | '''pack a FD FDM buffer from current values'''
213 | for i in range(len(self.values)):
214 | if math.isnan(self.values[i]):
215 | self.values[i] = 0
216 | return struct.pack(self.pack_string, *self.values)
--------------------------------------------------------------------------------