diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4391cc4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Python +__pycache__/ +build/ +dist/ +*.egg +*.egg-info/ + +# Latex +*.log +*.aux +*.toc +*.out +*.synctex.gz + +# Glade temporary files +*~ diff --git a/README.md b/README.md new file mode 100644 index 0000000..19863fb --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +Resume application project app icon + +# Nemo + +In drones or in robotics, brushless motors are becoming more and more common. However, choosing the right motor for the right application can be quite difficult. Indeed, understanding datasheets can be quite complexe: is a motor with a no-load speed of 5000rpm more powerful that one with the same torque, but specified for 3000rpm at max torque? How do you compare a motor with a KV of 500 rpm/V with one with a Kt of 0.5Nm/Arms? And what do these value even mean? Sometimes you might feel like nobody can answer - well now, **Nemo** can! + +**Nemo** is a *Nifty Evaluator for MOtors* - more practically, it is a tool to compare brushless motors (PMSM). While the choice of the "best" motor ultimately depends on the application, **Nemo** will help you in making a fair comparison between motors from various manufacturers, to truly understand their limit. + +Let's take an example: [My Actuator](https://www.myactuator.com/)'s pancake motors. How does the old [RMD-L-7025](https://www.myactuator.com/product-page/rmd-l-7025), equipped with a 1:6 gearbox, compare to the newer [RMD-X6 1:6](https://www.myactuator.com/product-page/rmd-x6). Well, here are the motor's characteristics (torque-speed curve) and specs for a direct comparison: +![](src/nemo_bldc/doc/Figures/overview.png) + +**Nemo** can be used to: + + - compare motors from different manufacturers and choose the best for a given application + - obtain detailed information about a motor, like output power, efficiency, required battery current... that may not be available on the datasheet + - more generally, learn about brushless motors, as the [full mathematical model is detailed here](src/nemo_bldc/doc/BrushlessMotorPhysics.pdf) + +Please see the [User Manual](doc/user_manual.pdf) for more information on the software. + +*Important note*: **Nemo** works by using the classical linear model of non-sallient PMSM. While this model is known to be fairly accurate (being the base of Field-Oriented Control), in practice non-linear phenomenons can alter motor performance (magnetic saturation, cogging, friction...). Also, motor parameters usually vary between one unit and another (manufacturers typically guarantee them by 10%). Thus, values from the manufacturer's datasheet may differ from those given by **Nemo**: when in doubt, don't hesitate to ask the manufacturer about their datasheet. As always in engineering, remain cautious and plan system dimensioning with a reasonable margin of error. + + + +## Installing Nemo + +### Through a python environment + +`Nemo` is distributed through `pip`: + +``` +pip install nemo_bldc +``` + +You can also install it from source by downloading this repo and running: + +``` +pip install . +``` + +### Windows binary + +TODO \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..0ad0def --- /dev/null +++ b/setup.py @@ -0,0 +1,30 @@ +from setuptools import setup, find_namespace_packages +from pathlib import Path + +setup( + name='nemo_bldc', + version='1.0.0', + description='A tool to evaluate and compare brushless motors', + long_description=Path('README.md').read_text('utf8'), + author="Matthieu Vigne", + license="MIT", + packages=find_namespace_packages('src'), + package_dir={'': 'src'}, + package_data={ + "nemo_bldc.ressources": ["*.glade", "*.json"], + "nemo_bldc.doc": ["*.pdf"] + }, + install_requires=[ + "pycairo", # Can cause issues if not installed before PyGObject - use pip and not setuptools to run install + "pytest", + "scipy", + "numpy", + "matplotlib", + "PyGObject"], + entry_points={ + 'console_scripts': [ + 'nemo_bldc = nemo_bldc.nemo:nemo_main' + ] + }, + include_package_data=True, + zip_safe=False) diff --git a/src/nemo_bldc/__init__.py b/src/nemo_bldc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/nemo_bldc/doc/BrushlessMotorPhysics.pdf b/src/nemo_bldc/doc/BrushlessMotorPhysics.pdf new file mode 100644 index 0000000..7121ad3 Binary files /dev/null and b/src/nemo_bldc/doc/BrushlessMotorPhysics.pdf differ diff --git a/src/nemo_bldc/doc/BrushlessMotorPhysics.tex b/src/nemo_bldc/doc/BrushlessMotorPhysics.tex new file mode 100644 index 0000000..0683d53 --- /dev/null +++ b/src/nemo_bldc/doc/BrushlessMotorPhysics.tex @@ -0,0 +1,451 @@ +\documentclass[a4paper,10pt]{article} +\usepackage[utf8]{inputenc} +\usepackage[T1]{fontenc} +\usepackage{amssymb} +\usepackage{mathtools} +\usepackage{bm} +\usepackage{pdflscape} +\usepackage[british]{babel} +\usepackage{gensymb} +\usepackage{array} +\usepackage{float} +\usepackage{wrapfig} +\usepackage{hyperref} + +\newtheorem{theorem}{Theorem} + +\title{Physics of PMSM motors} +\author{Matthieu Vigne} +\date{September 2022} + +\begin{document} + +\maketitle +\tableofcontents + +\section{Introduction} + +The goal of this document is to summarize the equations of dynamics of a brushless motor. Specifically, we are here looking at +so-called PMSM (permanent magnet synchronous machine) - basically a brushless motor without cogging, i.e. with +sinusoidal back-EMF. + +These equations are derived from the following PhD thesis and books: + + - [1] Nicolas Henwood, Estimation en ligne de paramètres de machines +électriques pour véhicule en vue d’un suivi de la +température de ses composants + + - [2] L. Chédot, Contribution à l'étude des machines +synchrones à aimants permanents internes à +large espace de fonctionnement. + Application à l'alterno-démarreur + + - [3] J. Chiasson. Modeling and High-Performance Control of Electric Machines. IEEE Press, 2005. + +We use slightly different variables at some times, as will be outlined in what follows. + +\section{Model equations} + +\subsection{Fundamental equations} + +We consider a star-shaped 3 phase brushless motor, with sinusoidal back-EMF. This motor is intrinsically characterized by only 4 fundamental constants: + +\begin{itemize} + \item $n$, the number of poles ; we denote $n_p \triangleq \frac{n}{2}$ the number of pole pairs. + \item $R$, the per-phase resistance + \item $L$, the per-phase inductance + \item $\phi$, the rotor magnetic flux generated by the $n_p$ pole pairs - or equivalently, the back-EMF constant $k_e \triangleq \sqrt{\frac{2}{3}}$ (see [3, p.452]). Because we will be working with current-invariant Clarke-Park transforms, we will use $k_e$ over $\phi$. +\end{itemize} + +Two other extrinsic limitations are present in a given setup: +\begin{itemize} + \item $U_{bat}$, a maximum voltage, due to power supply's voltage + \item $I_m$, a maximum RMS current: this current comes from thermal or power limitations. +\end{itemize} + +We denote $\theta$ the mechanical angle of the motor, $\theta_e \triangleq \frac{\theta}{n_p}$ is the electrical angle. Let $\bm \omega \triangleq \dot{\theta}$. + +Using Faraday's law of induction, the voltage across each phase writes: + +\begin{equation} + \left\{ + \begin{aligned} + U_a &= R i_a + L \frac{d}{dt} i_a - k_e \omega \sin \theta_e \\ + U_b &= R i_b + L \frac{d}{dt} i_b -k_e \omega \sin (\theta_e - \frac{2 \pi}{3}) \\ + U_c &= R i_c + L \frac{d}{dt} i_c - k_e \omega \sin (\theta_e + \frac{2 \pi}{3}) + \end{aligned} + \right. + \label{ePhase} +\end{equation} + +Notice that $k_e$ already takes into account the number of poles (i.e. there is no $n_p$ term in front). The $\sqrt{3/2}$ factor is explained in [8, p.452] + +Note furthermore that this equation (with sinusoidal back-EMF) implies that the motor has no saliency, i.e. no cogging: this simplifies a bit the equations found in these theses, as $L_d = L_q$. + +\subsection{From fixed to rotation frame: the Clark-Park} + +The second Kirchhoff law states that $i_a = i_b = i_c$: thus, a PMSM is described by only two equations. Furthermore, a steady-state formulation can be obtained when working in the rotating frame, attached to the rotor: this is expressed by the Clarke and Park transform. + +Note that, unlike Nicolas Henwood, we use \textbf{current-invariant} Clarke-Park transform, and not \textbf{power-invariant}. This is because motor controllers like Ingenia or Elmo use this current-invariant form ; this however introduces a scaling effect in the formulas. + +The direct, current-invariant transform, thus defines a quadrature and direct current as: + +\begin{equation} + \begin{pmatrix} + i_q \\ + i_q + \end{pmatrix} = \frac{2}{3} \begin{pmatrix} + \cos \theta_e & \sin \theta_e \\ + -\sin \theta_e & \cos \theta_e + \end{pmatrix} \begin{pmatrix} + 1 & \frac{-1}{2} & - \frac{1}{2} \\ + 0 & \frac{\sqrt{3}}{2}& - \frac{\sqrt{3}}{2} + \end{pmatrix} + \begin{pmatrix} + i_a \\ + i_b \\ + i_c + \end{pmatrix} + \label{eCarkePark} +\end{equation} + +One can easily show that, when $i_d = 0$, in sinusoidal regime the amplitude of $i_q$ and $i_{a,b,c}$ is the same, hence the name of current-invariant transform. Conversely, for power computation we have: + +\begin{equation} + i_a^2 + i_b^2 + i_c^2 = \frac{3}{2} (i_q^2 + i_d^2) + \label{ePower} +\end{equation} + +The inverse transform conversely writes: + +\begin{equation} + \begin{pmatrix} + i_a \\ + i_b \\ + i_c + \end{pmatrix} = + \frac{3}{2} \begin{pmatrix} + \frac{2}{3} & 0 \\ + -\frac{1}{3} & \frac{\sqrt{3}}{3} \\ + -\frac{1}{3} & -\frac{\sqrt{3}}{3} \\ + \end{pmatrix} + \begin{pmatrix} + \cos \theta & -\sin \theta \\ + \sin \theta & \cos \theta + \end{pmatrix} + \begin{pmatrix} + i_d \\ + i_q + \end{pmatrix} + \label{eClarkeParkInverse} +\end{equation} + +The same transform can be applied to the voltages as well, to define a direct and quadrature voltage: + +\begin{equation} + \begin{pmatrix} + u_q \\ + u_q + \end{pmatrix} = \frac{2}{3} \begin{pmatrix} + \cos \theta_e & \sin \theta_e \\ + -\sin \theta_e & \cos \theta_e + \end{pmatrix} \begin{pmatrix} + 1 & \frac{-1}{2} & - \frac{1}{2} \\ + 0 & \frac{\sqrt{3}}{2}& - \frac{\sqrt{3}}{2} + \end{pmatrix} + \begin{pmatrix} + U_a \\ + U_b \\ + U_c + \end{pmatrix} + \label{eCarkeParkV} +\end{equation} + +\subsection{Torque Equation} + +To compute the torque, [8, p.454] performs a direct computation of the Lorenz force applied by the rotor onto the coils, to obtain the following relationship: + +\begin{equation} + \tau = \sqrt{\frac{3}{2}} k_e i_q +\end{equation} + + +\subsection{Obtaining the electric differential equation} + +To obtain a minimal representation of the system's dynamics, the idea is to use the Clarke-Park transform on the phase dynamics \eqref{ePhase}. + +The derivative of \eqref{eCarkePark} yields + +\begin{equation} + \frac{d}{dt} + \begin{pmatrix} + i_q \\ + i_q + \end{pmatrix} = n_p \omega \begin{pmatrix} + i_q \\ + -i_d +\end{pmatrix} + \frac{2}{3} \begin{pmatrix} + \cos \theta_e & \sin \theta_e \\ + -\sin \theta_e & \cos \theta_e + \end{pmatrix} \begin{pmatrix} + 1 & \frac{-1}{2} & - \frac{1}{2} \\ + 0 & \frac{\sqrt{3}}{2}& - \frac{\sqrt{3}}{2} + \end{pmatrix} + \frac{d}{dt} + \begin{pmatrix} + i_a \\ + i_b \\ + i_c + \end{pmatrix} + \label{eCarkeParkDiff} +\end{equation} + +Then multiplying by $L$ using \eqref{ePhase} we get +\begin{equation} + \begin{aligned} + L \frac{d}{dt} + \begin{pmatrix} + i_q \\ + i_q + \end{pmatrix} &= n_p L \omega \begin{pmatrix} i_q \\ -i_d \end{pmatrix} + + \frac{2}{3} \begin{pmatrix} + \cos \theta_e & \sin \theta_e \\ + -\sin \theta_e & \cos \theta_e + \end{pmatrix} \begin{pmatrix} + 1 & \frac{-1}{2} & - \frac{1}{2} \\ + 0 & \frac{\sqrt{3}}{2}& - \frac{\sqrt{3}}{2} + \end{pmatrix} + \begin{pmatrix} + U_a - R i_a + k_e \omega \sin \theta_e \\ + U_b - R i_b + k_e \omega \sin (\theta_e - \frac{2 \pi}{3})\\ + U_c - R i_c + k_e \omega \sin (\theta_e + \frac{2 \pi}{3}) + \end{pmatrix}\\ + &= n_p L \omega \begin{pmatrix} i_q \\ -i_d \end{pmatrix} + + \begin{pmatrix} u_q \\ u_d \end{pmatrix} + - R \begin{pmatrix} i_q \\ i_d \end{pmatrix} + + k_e \omega \begin{pmatrix} 0 \\ -1 \end{pmatrix} + \end{aligned} +\end{equation} + +\subsection{Electrical bounds} + +We now need to take into account the fact that current and voltage are both limited. + +For the current, the relationship is simple: from \eqref{ePower} we get the following inequality + +\begin{equation} + i_q^2 + i_d^2 \leq 2 I_m +\end{equation} + +Concerning the voltage, the limit arises from the fact that, at any given time, the phase-to-phase voltage +must be less than the input battery voltage $U$. This writes: +\begin{equation} + \begin{aligned} + |U_a - U_b| &\leq U_{bat} \\ + |U_a - U_c| &\leq U_{bat} \\ + |U_b - U_c| &\leq U_{bat} \\ + \end{aligned} +\end{equation} + +Using the inverse Park-Clarke transform \eqref{eClarkeParkInverse}, this yields: + +\begin{equation} + \begin{aligned} + \sqrt{3} |\cos \theta_e u_q + \sin \theta_e u_d| &\leq U_{bat} \\ + \sqrt{3} |\cos (\theta_e + \frac{2 \pi}{3}) u_q + \sin (\theta_e + \frac{2 \pi}{3}) u_d| &\leq U_{bat} \\ + \sqrt{3} |\cos (\theta_e - \frac{2 \pi}{3}) u_q + \sin (\theta_e - \frac{2 \pi}{3}) u_d| &\leq U_{bat} \\ + \end{aligned} +\end{equation} + +We thus obtain inequalities that depend on $\theta$. A sufficient condition is thus for this inequality to work for every $\theta_e$. Computing the maximum of this function over $\theta_e$, we get the following inequality + +\begin{equation} + u_q^2 + u_d^2 \leq \frac{U_{bat}^2}{3} +\end{equation} + +\subsection{Summary} + +To sum up, a PMSM, seen as a torque source, is described by + +\begin{itemize} + \item Two differential equations describing the evolution of the electrical states + \item An algebraic equation giving the torque as a function of the current + \item Two inequalities for the current and voltage limit. +\end{itemize} + +This model thus writes + +\begin{equation} + \boxed{\begin{aligned} + &\left\{ + \begin{aligned} + L \frac{d i_d}{dt} &= - R i_d + n_p\omega L i_q + u_d \\ + L \frac{d i_q}{dt} &= - R i_q - \omega (n_p L i_d + k_e) + u_q\\ + \tau &= \frac{3}{2} k_e i_q + \end{aligned} + \right.\\ + \text{with:} + &\left\{ + \begin{aligned} + i_q^2 + i_d^2 &\leq 2 {I_m}^2 \\ + u_q^2 + u_d^2 &\leq \frac{U_{bat}^2}{3} + \end{aligned} + \right. + \end{aligned}} + \label{eFullModel} +\end{equation} + + +\section{Model analysis} + +Having derived the equations of the PMSM, we now analyze them to understand the motor's behavior, characteristics and limits. + +More precisely, we consider a motor with a gearbox of ratio $\rho$, and redefine $\tau$ and $\omega$ as the output (articular) parameters, such that \eqref{eFullModel} becomes + +\begin{equation} + \begin{aligned} + &\left\{ + \begin{aligned} + L \frac{d i_d}{dt} &= - R i_d + n_p \rho \omega L i_q + u_d \\ + L \frac{d i_q}{dt} &= - R i_q - \rho \omega (n_p L i_d + k_e) + u_q\\ + \tau &= \frac{3}{2} \rho k_e i_q + \end{aligned} + \right.\\ + \text{with:} + &\left\{ + \begin{aligned} + i_q^2 + i_d^2 &\leq 2 {I_m}^2 \\ + u_q^2 + u_d^2 &\leq \frac{U_{bat}^2}{3} + \end{aligned} + \right. + \end{aligned} + \label{eFullModelArticular} +\end{equation} + + +\subsection{Directly derived constants} + +Here, we compute a bunch of simple constants, which are often present in motor datasheet, and give some information about the motor's behavior: + +\begin{itemize} + \item \textbf{Back EMF constant, phase to phase}: this constant is useful as it directly gives the no-load maximum speed: + \begin{equation} + k^{pp}_e \triangleq \sqrt{3} k_e \qquad \frac{V}{rad/s} + \end{equation} + \item \textbf{Maximum (no load) speed} (articular): more details is given in Section~\ref{sMaxSpeed} + \begin{equation} + \omega_{nl} \triangleq \frac{U_{bat}}{\rho k^{pp}_e} = \frac{U_{bat}}{\sqrt{3} \rho k_e} \quad rad/s + \end{equation} + \item \textbf{Torque constant} (articular): proportionality ratio between quadrature current and torque: + \begin{equation} + k^q_t \triangleq \frac{3}{2} \rho k_e \qquad \frac{Nm}{A} + \eqref{eKt} + \end{equation} + + \item \textbf{Motor constant}(articular): this indicates how much heat is dissipated for a given torque: indeed, the thermal power is $P_th = R (i_a^2 + i_b^2 + i_c^2) \triangleq \frac{1}{K_m^2} \tau$ - this yields + \begin{equation} + K_m \triangleq \sqrt{\frac{2}{3}} \frac{k^q_t}{\sqrt{R}} = \sqrt{\frac{3}{2}} \frac{\rho k_e}{\sqrt{R}} \qquad \frac{Nm}{\sqrt{W}} + \end{equation} + \item \textbf{Maximum torque} (articular): + \begin{equation} + \tau_m \triangleq \frac{k^t_q}{\sqrt{2}} I_m = \frac{3}{2 \sqrt{2}} \rho k_e I_m \qquad Nm + \end{equation} + + \item \textbf{Defluxing ratio}: in French \emph{réaction d'induit}, this ratio is an approximation of how much the speed can be increased by defluxing. + \begin{equation} + r_{dflux} \triangleq \frac{n_p L I_m}{\sqrt{2} k_e} + \end{equation} +\end{itemize} + +\subsection{Maximum speed and defluxing} \label{sMaxSpeed} + + +The motor speed is limited by the input voltage: indeed, induction forces and resistive loss limit the voltage at high velocity and high torque. + +To obtain the limit velocity, the idea is to neglect the $L \frac{d}{dt}$ term in \eqref{eFullModelArticular}, to obtain the point where, at constant torque, the motor may operate. $L$ is indeed very small ; more importantly, we are here talking about the quadrature current, not the phase current (which oscillates rapidly). + +Under this condition, $u_d$ and $u_q$ are algebraically given as a function of the current: +\begin{equation} +\begin{aligned} + u_d &= R i_d - n_p \rho \omega L i_q \\ + u_q &= R i_q + \rho \omega (n_p L i_d + k_e)\\ +\end{aligned} +\label{eVelocityVoltage} +\end{equation} + +\subsubsection{Maximum speed when not defluxing} + +When no defluxing is in effect, $i_d = 0$. Then the voltage inequality in \eqref{eFullModelArticular} translate into: +\begin{equation} + (n_p \rho \omega L i_q) ^2 + (R i_q + \rho \omega k_e)^2 \leq \frac{U_{bat}^2}{3} +\end{equation} + +We thus obtain a second-order polynomial: $a \omega^2 + b \omega + c = 0$, with + +\begin{equation} + a = \rho^2 ((n_p L i_q)^2 + k_e^2) \qquad + b = 2 \rho R k_e i_q \qquad + c = (R i_q)^2 - \frac{U_{bat}^2}{3} +\end{equation} + +This polynomial typically has two real roots. One of them is negative, and has little practical interest: it corresponds to the moment where, in generator mode, the phase voltage is equal to the battery voltage, while the motor is braking at maximum torque. This will never happen - unless an excessively large load is able to so thoroughly over-power the motor. Note that for high currents, the roots might be imaginary: again this is non-physical. Indeed, before the discriminant becoming negative, at one point the maximum velocity will become negative. This indicates that the motor is no longer able to turn - physically, it's probably because the voltage drop in the phase resistor becomes as large as the battery voltage itself. + +Thus, the maximum velocity at a given torque is given by the positive root of this equation. Note that for $i_q=0$, this simplifies into: $ \omega = \frac{U_{bat}}{\sqrt{3}\rho k_e} = \frac{U_{bat}}{\rho k^{pp}_e}$. + +\subsubsection{Defluxing} + +As we have seen in the previous section, the velocity limit arises due to a voltage limit, cause by the induction term $\omega k_e$. The idea of defluxing is to send a negative direct current $i_d$: this current will not modify the torque, but will reduce the induction term $\omega (n_p L i_q + k_e)$. This will effectively reduce the quadrature voltage $u_q$, but, due to the resistor $R$, will also increase the direct voltage $u_d$: this limits the defluxing capability of a given motor. + +Another limit is the current limit, which implies that it is not always possible to completely cancel out the flux rotor $k_e$. The so-called defluxing ratio (\emph{réaction d'induit}) $r_{dflux} \triangleq \frac{n_p L I_m}{\sqrt{2} k_e}$ indicates this capacity. A motor will a defluxing ratio smaller than one cannot completely cancel out the magnetic flux, even at zero torque, and thus has a finite maximum velocity. By contrast, a motor with a defluxing ratio larger than or equal to one can do it - and in theory has an infinite velocity at zero torque (thus a finite mechanical power nonetheless). Note of course that in practice, mechanical friction will limit the effective velocity. + +\bigskip + +\paragraph{Defluxing current} + +Given a target quadrature current $i_q$ (i.e. target torque) and a target velocity $\omega$, we compute the required defluxing current as the current that satisfies the voltage inequality: +\begin{equation} + (R i_d - n_p \rho \omega L i_q) ^2 + (R i_q + \rho \omega (n_p L i_d + k_e))^2 = \frac{U_{bat}^2}{3} + \label{eIneqDeflux} +\end{equation} + +Once again, we get a second-order polynomial in $i_d$, with coefficients + +\begin{equation} + \begin{aligned} + a &= R^2 + (\rho \omega n_p L)^2 \qquad + b = 2 n_p L k_e (\rho \omega)^2 \\ + c &= (\rho \omega n_p L i_q)^2 + 2 R i_q k_e \rho \omega + R^2 i_q^2 + (k_e \rho \omega)^2 - \frac{U_{bat}^2}{3} + \end{aligned} +\end{equation} + +We typically obtain two real roots. If the largest is positive, this indicates that there is no need to perform defluxing: indeed, to reach the maximum voltage we need to add \emph{positive} direct current. This is of course not the point (at is would only contribute to increasing the power dissipation), thus in that case we do not deflux. If both roots are negative, this gives a range of applicable defluxing current - there again, we take the largest root (smallest in absolute value) to avoid unnecessary power loss. It is however worth mentioning that this presence of multiple solution means that the deflux current is not unique: rather, it is only a minimum. In typical cases this only means that we need to put \emph{at least} this much current for the motor to spin at the desired speed - but we can put more. This is a nice property, given that we have some uncertainty on the actual parameters of a given motor: this shouldn't impact our capacity to deflux it. + +To summarize, the defluxing current needed to reach a given torque and speed writes: + +\begin{equation} + i_d = \min\left(0, \frac{-b + \sqrt{b^2 - 4 a c}}{2 a}\right) +\end{equation} + + +\paragraph{Maximum speed} + +In practice, the defluxing current cannot be as large as we want, as we must still satisfy the current inequality in \eqref{eFullModelArticular}. Thus, considering a given torque, thus a given $i_q$, a velocity limit is still present. + +We can obtain a closed-form solution of this maximum velocity by noticing that, in \eqref{eIneqDeflux}, the right hand side is minimal when $i_d = -\frac{k_e}{n_p L}$\footnote{This is clear if $i_d < 0$ ; considering that on a real motor, $R < \rho \omega n_p L$, the voltage also increases when applying a positive direct current.}. Thus, we can compute the maximum defluxing current as + +\begin{equation} + i_d = \max \left(-\frac{k_e}{n_p L}, -\sqrt{2 I_m^2 - i_q^2} \right) +\end{equation} + +Then, given $i_d$ and $i_q$, \eqref{eIneqDeflux} is again a second-order polynomial in $\omega$, whose positive root gives the maximum velocity. Its coefficients are: + +\begin{equation} + \begin{aligned} + a &= (\rho n_p L i_q) + \rho^2(n_p L i_d + k_e)^2 \qquad + b = 2 \rho R i_q k_e \\ + c &= R^2 (i_d^2 + i_q^2) - \frac{U_{bat}^2}{3} + \end{aligned}e +\end{equation} + + +\end{document} \ No newline at end of file diff --git a/src/nemo_bldc/doc/Figures/compare.png b/src/nemo_bldc/doc/Figures/compare.png new file mode 100644 index 0000000..7e2c6c9 Binary files /dev/null and b/src/nemo_bldc/doc/Figures/compare.png differ diff --git a/src/nemo_bldc/doc/Figures/compare.svg b/src/nemo_bldc/doc/Figures/compare.svg new file mode 100644 index 0000000..2d63675 --- /dev/null +++ b/src/nemo_bldc/doc/Figures/compare.svg @@ -0,0 +1,115 @@ + + + +123 diff --git a/src/nemo_bldc/doc/Figures/main_menu.png b/src/nemo_bldc/doc/Figures/main_menu.png new file mode 100644 index 0000000..fbb0404 Binary files /dev/null and b/src/nemo_bldc/doc/Figures/main_menu.png differ diff --git a/src/nemo_bldc/doc/Figures/main_menu.svg b/src/nemo_bldc/doc/Figures/main_menu.svg new file mode 100644 index 0000000..c64349c --- /dev/null +++ b/src/nemo_bldc/doc/Figures/main_menu.svg @@ -0,0 +1,3243 @@ + + + +123 diff --git a/src/nemo_bldc/doc/Figures/motor_creation_helper.png b/src/nemo_bldc/doc/Figures/motor_creation_helper.png new file mode 100644 index 0000000..1b30d27 Binary files /dev/null and b/src/nemo_bldc/doc/Figures/motor_creation_helper.png differ diff --git a/src/nemo_bldc/doc/Figures/motor_creation_widget.png b/src/nemo_bldc/doc/Figures/motor_creation_widget.png new file mode 100644 index 0000000..b01f52e Binary files /dev/null and b/src/nemo_bldc/doc/Figures/motor_creation_widget.png differ diff --git a/src/nemo_bldc/doc/Figures/overview.png b/src/nemo_bldc/doc/Figures/overview.png new file mode 100644 index 0000000..8b895e1 Binary files /dev/null and b/src/nemo_bldc/doc/Figures/overview.png differ diff --git a/src/nemo_bldc/doc/Figures/single_motor_tab.png b/src/nemo_bldc/doc/Figures/single_motor_tab.png new file mode 100644 index 0000000..176cad1 Binary files /dev/null and b/src/nemo_bldc/doc/Figures/single_motor_tab.png differ diff --git a/src/nemo_bldc/doc/Figures/single_motor_tab.svg b/src/nemo_bldc/doc/Figures/single_motor_tab.svg new file mode 100644 index 0000000..8b09f17 --- /dev/null +++ b/src/nemo_bldc/doc/Figures/single_motor_tab.svg @@ -0,0 +1,135 @@ + + + +12345 diff --git a/src/nemo_bldc/doc/Figures/tab_single.png b/src/nemo_bldc/doc/Figures/tab_single.png new file mode 100644 index 0000000..5ed6944 Binary files /dev/null and b/src/nemo_bldc/doc/Figures/tab_single.png differ diff --git a/src/nemo_bldc/doc/__init__.py b/src/nemo_bldc/doc/__init__.py new file mode 100644 index 0000000..a3e0eba --- /dev/null +++ b/src/nemo_bldc/doc/__init__.py @@ -0,0 +1,4 @@ +import pkg_resources + +def get_doc_path(filename:str): + return pkg_resources.resource_filename(__name__, filename) diff --git a/src/nemo_bldc/doc/logo.png b/src/nemo_bldc/doc/logo.png new file mode 100644 index 0000000..985d165 Binary files /dev/null and b/src/nemo_bldc/doc/logo.png differ diff --git a/src/nemo_bldc/doc/user_manual.md b/src/nemo_bldc/doc/user_manual.md new file mode 100644 index 0000000..f449412 --- /dev/null +++ b/src/nemo_bldc/doc/user_manual.md @@ -0,0 +1,136 @@ +--- +title: "Nemo V1.0 - User manual" +geometry: "left=3cm,right=3cm,top=3cm,bottom=3cm" +output: pdf_document +linkcolor: red +numbersections: true +--- + +\tableofcontents + +# Introduction + +**Nemo** is a tool to compare motors and actuators. +The notion of motor and actuator is actually merged into one: for the software, a 'motor' is a real +motor, plus a reduction ratio (only simple transmissions are supported). + +Nemo is made of several independant tabs, each with a configuration sidebar and a plot. Each tab is described below. + +*General remark*: Because Matplotlib is quite slow, the plot is only updated on demand: whenever the user does a modification, + a 'Update plot' button appears: this allows the user to do several modifications before asking for a plot update. + +# Defining a motor + +A motor (actuator) is parametrized in Nemo by 8 parameters: + + - $R$, the per-phase resistor + - $L$, the per-phase inductance + - $k_e$, the (single phase) BEMF constant + - $n$, the number of poles + - $I_{max}$, the maximum quadrature current + - $I_{nominal}$, the nominal quadrature current + - $\rho$, the reduction ratio + - $U$, the input voltage + +Additionnaly, a motor also has a name, used to create a legend. + + +These parameters can be set manually through the GUI, in the motor configuration widget shown below.\ +![](Figures/motor_creation_widget.png) + +To help you enter these values correctly, clicking on the "Helper" button will bring a dialog which will compute the +corresponding per-phase value from datasheet information (e.g. line to line values, delta motors, magnet caracterized in +terms of $k_v$, $k_t$ or $k_e$...) +![](Figures/motor_creation_helper.png) + +Additionally, a 'motor library' provides pre-configured motors for faster, error-free loading. These motor are then available +from a drop-down menu in the motor creation widget. + +## Adding a motor to the library + +The motor library is simply a json file describing each motor: the format is given below. A custom json file can be +loaded using the "motor library file" button at the top. +``` + "MyActuator RMD-X6 V3": + { + "R": 0.275, + "L": 0.090, + "ke": 0.0900, + "i_quadrature_max": 10.8, + "i_quadrature_nominal": 3.6, + "np": 28, + "U": 48, + "reduction_ratio": 8 + }, +``` + +\pagebreak + +# The main window + +The top of the main window gives access to several functions: + +![](Figures/main_menu.png) + + - 1: *library loading button*: use this to replace the default library by another library json file + - 2: *external plot switch*: The main objective of Nemo is to plot graphs. But because part of the screen is taken the GUI, the plot is not that big. + To overcome this issue, the plot can be done in its own floating window, by activating this switch. When the 'external plot' switch is on, the plot is done on + another window, wich can be maximized / move to another screen. To return to the single window mode, you can either deactivate the + switch, or minimize the external window (*note that the external window cannot be closed, only minimized*) + + - 3: *help* A drop-down menu, with a link to this user manual, and the mathematical documentation explaining the underlying model and computations. + +\pagebreak + +# The 'Compare' Tab + +**Objective**: to compare the theoretical caracteristics of several motors + +![](Figures/compare.png) + +**GUI** + + - 1: *motor list* this lists the motors being compared. Elements in the list can be selected ; the selected element can be removed by the 'Remove' button, while the 'Add' button + adds a new motor to the list. + - 2: *motor properties*: this indicates the properties of the selected motor, which can thus be editted and modified. + - 3: *derived constants*: this passive list shows a set of constants derived from the motor parameters. See the mathematical documentation for an explaination of each + constant. + +**Plot**: each motor is plotted in the specified color: + + - the full line plot is the caracteristic, at the specified max torque & battery voltage + - the dotted line indicates the nominal current (thus nominal torque) limit + - the lighter-colored curve is the defluxing limit of the motor. + +\pagebreak + +# The 'Single motor analysis' Tab + +**Objective**: study more in details the behavior of a single motor - including efficiency thermal variation, battery power draw... + +![](Figures/single_motor_tab.png) + + +**GUI** + + - 1: *motor properties*: the properties of the plotted motor, at the nominal temperature + - 2: *plot type*: the type of information being plotted in the color plot: + - the mechanical power + - the thermal power + - the total (mecanical + thermal) power + - the efficiency (mecanical / total power) + - the battery state (drawn battery current, predicted battery voltage, obtained by modeling the battery as a constant voltage source + serie resistor) + - 3: when plotting the battery state, the battery's internal resistance + - 4: *temperature information*: coefficient of the simple linear thermal model applied: + - the stator's resistance increases linearily with temperature, with a given coefficient (0.4%/°C is the nominal value for copper) + - the rotor's magnetic flux decreases linearily with temperature, with a given coefficient (-0.12%/°C is a typical value for Nd magnet) + - 5: *derived constants*: several derived constants, with their variation between nominal and heated value. + +**Plot** + + - In black, the caracteristic of the motor at nominal temperature + - In green, the heated motor caracteristic + - The color map depends on the plot type + +*Example*: in this picture, the rotor and stator are both heated by 25°C from their nominal temperature. As a result, the torque decreases (due to a lower $k_e$). The increase +in $R$ reduces the max velocity at max torque, while the reduction of $k_e$ decreases the back EMF, hence a larger no-load speed. diff --git a/src/nemo_bldc/doc/user_manual.pdf b/src/nemo_bldc/doc/user_manual.pdf new file mode 100644 index 0000000..43c8a94 Binary files /dev/null and b/src/nemo_bldc/doc/user_manual.pdf differ diff --git a/src/nemo_bldc/gui/__init__.py b/src/nemo_bldc/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/nemo_bldc/gui/abstract_tab.py b/src/nemo_bldc/gui/abstract_tab.py new file mode 100644 index 0000000..e3a936f --- /dev/null +++ b/src/nemo_bldc/gui/abstract_tab.py @@ -0,0 +1,77 @@ +import typing as tp +import numpy as np + +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk, Gdk, GLib, GdkPixbuf + +from matplotlib.backends.backend_gtk3agg import ( + FigureCanvasGTK3Agg as FigureCanvas) + +from matplotlib.backends.backend_gtk3 import ( + NavigationToolbar2GTK3 as NavigationToolbar) + +from matplotlib.figure import Figure + +from .widget_motor_creation import MotorCreationWidget +from ..ressources import get_ressource_path + +class AbstractTab: + ''' + A tab of the analysis tool + ''' + def __init__(self, name, sidebar_widget): + sidebar_widget.set_size_request(300, -1) + self.name = name + + builder = Gtk.Builder() + builder.add_from_file(get_ressource_path("abstract_tab.glade")) + self.tab_widget = builder.get_object("paned") + # When designing in glade, the scroll widget is always the first + # child: move it in second position. + child = self.tab_widget.get_child1() + self.tab_widget.remove(child) + self.tab_widget.pack1(sidebar_widget, False, False) + self.tab_widget.pack2(child, True, False) + self.scroll_widget = builder.get_object("scroll_plot") + self.motor_widget = MotorCreationWidget() + builder.connect_signals(self) + + + def configure_plot(self, matplotlib_plot): + canvas = FigureCanvas(matplotlib_plot) + canvas.set_size_request(800, 600) + + vbox = Gtk.VBox() + toolbar = NavigationToolbar(canvas, self.scroll_widget) + vbox.pack_start(toolbar, False, False, 0) + vbox.pack_start(canvas, True, True, 0) + + self.button = Gtk.Button(label="Update plot") + label = self.button.get_child() + label.set_markup("Update plot") + self.button.connect("clicked", self.user_asked_for_update) + self.button.set_halign(Gtk.Align.CENTER) + self.button.set_valign(Gtk.Align.START) + self.button.set_margin_top(50) + + overlay = Gtk.Overlay() + self.scroll_widget.add(overlay) + overlay.add(vbox) + overlay.add_overlay(self.button) + + self.button.set_visible(False) + + def plot_need_update(self): + self.button.set_visible(True) + + def user_asked_for_update(self, *args): + self.button.set_visible(False) + self.update_plot() + + def update_library(self, library_data : "dict"): + ''' Called when the main window library parameter has been updated ''' + self.motor_widget.update_library(library_data) + + def update_plot(self): + pass diff --git a/src/nemo_bldc/gui/gui_compare_motors.py b/src/nemo_bldc/gui/gui_compare_motors.py new file mode 100644 index 0000000..0942391 --- /dev/null +++ b/src/nemo_bldc/gui/gui_compare_motors.py @@ -0,0 +1,145 @@ +import typing as tp +import numpy as np +from matplotlib.figure import Figure + +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk, Gdk, GLib, GdkPixbuf + +from .widget_motor_creation import DisplayMotor +from .abstract_tab import AbstractTab +from .utils import * +from ..ressources import get_ressource_path + + +def motor_to_treeview(mot): + return [color_to_pixbuf(mot.get_gtk_color()), + mot.name, + f"{1 / mot.K_m_art**2:.4f}", + f"{mot.kt_q_art:.3f}", + f"{mot.nominal_power:.1f}"] + +class CompareMotors(AbstractTab): + ''' + GUI tab: compare two motors + ''' + def __init__(self): + builder = Gtk.Builder() + builder.add_from_file(get_ressource_path("compare_tab.glade")) + super().__init__("Compare", builder.get_object("side_box")) + + # Get the widgets we need from the builder. + self.compare_entry_R = builder.get_object("compare_R") + self.compare_entry_L = builder.get_object("compare_L") + self.compare_entry_ke = builder.get_object("compare_ke") + self.compare_entry_I = builder.get_object("compare_I") + self.compare_entry_n = builder.get_object("compare_n") + + self.mpl_fig = Figure(figsize=(5, 4), dpi=100) + ax = self.mpl_fig.add_subplot() + self.mpl_fig.subplots_adjust(left=0.2, bottom=0.15, right=0.97, top=0.97, wspace=0, hspace=0) + def format_coord(x, y): + return f"Velocity: {x:.1f}rad/s ({x * 30 / np.pi:.1f}rpm), Torque: {y:.1f}Nm" + ax.format_coord = format_coord + + self.configure_plot(self.mpl_fig) + + self.tree_view = builder.get_object("tree_view") + self.tree_view.get_selection().connect("changed", self.tree_changed) + self.motor_compare_list = builder.get_object("motor_compare_list") + + self.selected_motor = None + self.motors = [] + + box = builder.get_object("side_box") + box.pack_start(self.motor_widget.top_frame, False, False, 0) + box.reorder_child(self.motor_widget.top_frame, 2) + self.motor_widget.connect("motor_updated", self.motor_updated) + self.motor_widget.connect("motor_name_updated", self.motor_name_updated) + + self.motorList = builder.get_object("motorList") + self.n_motor_created = 0 + + self.grid_ctes = builder.get_object("grid_ctes") + self.grid_column = [] + self.grid_sep = [Gtk.Separator() for _ in DISPLAYED_CTES] + + grid_labels = builder.get_object("grid_labels") + for i, (field, unit, _) in enumerate(DISPLAYED_CTES): + grid_labels.insert_row(2 * i + 1) + grid_labels.attach(Gtk.Label(label=field), 0, 2 * i + 1, 1 ,1) + grid_labels.attach(Gtk.Label(label=unit), 1, 2 * i + 1, 1, 1) + grid_labels.insert_row(2 * i + 2) + grid_labels.attach(Gtk.Separator(), 0, 2 * i + 2, 2, 1) + + builder.connect_signals(self) + + def param_update(self): + for i, m in enumerate(self.motors): + self.grid_column[i][0].set_markup(f"{m.name}") + for (_, _, func), label in zip(DISPLAYED_CTES, self.grid_column[i][1:]): + label.set_text(func(m)) + self.plot_need_update() + + def update_plot(self): + plot_caracteristics(self.mpl_fig.gca(), self.motors, [m.color for m in self.motors]) + self.mpl_fig.canvas.draw() + + def motor_updated(self, *args): + if self.selected_motor is not None: + self.motors[self.selected_motor] = self.motor_widget.motor + self.motor_compare_list[self.selected_motor][2] = f"{1 / self.motor_widget.motor.K_m_art**2:.3f}" + self.motor_compare_list[self.selected_motor][3] = f"{self.motor_widget.motor.kt_q_art:.3f}" + self.motor_compare_list[self.selected_motor][4] = f"{self.motor_widget.motor.nominal_power:.1f}" + self.param_update(), + + def tree_changed(self, selection): + # Get selection id + model, treeiter = selection.get_selected() + if treeiter is None: + self.selected_motor = None + else: + self.selected_motor = int(str(model[treeiter].path)) + self.motor_widget.set_motor(self.motors[self.selected_motor]) + + def add_motor(self, *args): + name = list(self.motor_widget.motor_library.keys())[0] + m = self.motor_widget.motor_library[name] + mot = DisplayMotor(m, name,f"C{self.n_motor_created}") + self.n_motor_created += 1 + self.motors.append(mot) + self.motor_compare_list.append(motor_to_treeview(mot)) + + # Create new column. + nc = len(self.grid_column) + self.grid_ctes.insert_column(2 * nc) + self.grid_ctes.attach(Gtk.Separator(), 2 * nc, 0, 1, 2 * len(DISPLAYED_CTES) + 1) + self.grid_ctes.insert_column(2 * nc + 1) + self.grid_column.append([Gtk.Label(label='label')] + [Gtk.Label(label='label') for _ in DISPLAYED_CTES]) + for i, label in enumerate(self.grid_column[-1]): + self.grid_ctes.attach(label, 2 * nc + 1, 2 * i - 1, 1, 1) + self.grid_ctes.attach(Gtk.Separator(), 2 * nc + 1, 2 * i, 1, 1) + + self.grid_ctes.show_all() + + # Set cursor to new value + self.tree_view.set_cursor(len(self.motors) - 1) + + def remove_motor(self, *args): + if self.selected_motor is not None: + idx = self.selected_motor + del self.motors[idx] + del self.grid_column[idx] + self.motor_compare_list.clear() + for mot in self.motors: + self.motor_compare_list.append(motor_to_treeview(mot)) + self.tree_view.set_cursor(Gtk.TreePath()) + self.grid_ctes.remove_column(2 * idx) + self.grid_ctes.remove_column(2 * idx) + + def motor_name_updated(self, *args): + if self.selected_motor is not None: + new_name = self.motor_widget.motor.name + self.motors[self.selected_motor].name = new_name + self.motor_compare_list[self.selected_motor][1] = new_name + self.grid_column[self.selected_motor][0].set_text(new_name) diff --git a/src/nemo_bldc/gui/gui_single_motor_perf.py b/src/nemo_bldc/gui/gui_single_motor_perf.py new file mode 100644 index 0000000..e28a78d --- /dev/null +++ b/src/nemo_bldc/gui/gui_single_motor_perf.py @@ -0,0 +1,166 @@ +import typing as tp +import numpy as np +from matplotlib.figure import Figure +import matplotlib as mpl +import matplotlib.cm as mcolormaps + +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk, Gdk, GLib, GdkPixbuf + +from .abstract_tab import AbstractTab +from .utils import * +from ..physics.battery import get_battery_state +from ..physics.motor import Motor + +from ..ressources import get_ressource_path + +DISPLAYED_CTES = [('Km, articular', 'Nm/\u221AW', lambda m: f"{m.K_m_art:.3f}"), + ('', 'W /Nm\u00B2', lambda m: f"{1 / m.K_m_art**2:.3f}"), + ('Power (non defluxing)', 'W', lambda m: f"{m.w_max_at_max_torque * m.iq_max * m.kt_q_art:.1f}"), + ('Ke', 'V/rad.s', lambda m: f"{m.ke:.3f}"), + ('Ktq, articular', 'Nm/A', lambda m: f"{m.kt_q_art:.3f}"), + ('Max torque, articular', 'Nm', lambda m: f"{m.tau_max:.1f}"), + ('No load speed, articular', 'rad/s', lambda m: f"{m.w_max_no_load:.1f}"), + ('', 'rpm', lambda m: f"{30 / np.pi * m.w_max_no_load:.0f}"), + ('Max speed @ max torque', 'rad/s', lambda m: f"{m.w_max_at_max_torque:.1f}"), + ('', 'rpm', lambda m: f"{30 / np.pi * m.w_max_at_max_torque:.0f}"), + ('R', 'Ohm', lambda m: f"{m.R:.3f}")] + +class SingleMotorPerfTab(AbstractTab): + ''' + GUI tab: look at the caracteristics of a single motor + ''' + def __init__(self): + builder = Gtk.Builder() + builder.add_from_file(get_ressource_path("single_motor_tab.glade")) + super().__init__("Single motor analysis", builder.get_object("side_box")) + + # Get the widgets we need from the builder. + self.spin_bat_res = builder.get_object("spin_bat_res") + self.spin_nominal_T = builder.get_object("spin_nominal_T") + self.spin_flux_var = builder.get_object("spin_flux_var") + self.spin_R_var = builder.get_object("spin_R_var") + self.spin_rotor = builder.get_object("spin_rotor_temp") + self.spin_stator = builder.get_object("spin_stator_temp") + + self.motors = [Motor(1, 0.01, 0.0001, 1.0, 1.0, 1.0, 1.0, 1.0) for _ in range(2)] + + self.label_specs = [] + for p in ["nominal_", "thermal_"]: + self.label_specs.append(builder.get_object(f"{p}spec")) + + spec_legend = builder.get_object("spec_legend") + spec_unit = builder.get_object("spec_unit") + spec_legend.set_text("\n".join([d[0] for d in DISPLAYED_CTES])) + spec_unit.set_text("\n".join([d[1] for d in DISPLAYED_CTES])) + + box = builder.get_object("side_box") + box.pack_start(self.motor_widget.top_frame, False, False, 0) + box.reorder_child(self.motor_widget.top_frame, 0) + self.motor_widget.connect("motor_updated", self.input_updated) + + self.mpl_fig = Figure(figsize=(5, 4), dpi=100) + ax = self.mpl_fig.add_subplot() + self.mpl_fig.subplots_adjust(left=0.08, bottom=0.15, right=1.0, top=0.97, wspace=0, hspace=0) + self.cbar = self.mpl_fig.colorbar(mpl.cm.ScalarMappable(mpl.colors.Normalize(vmin=0, vmax=1000))) + + self.configure_plot(self.mpl_fig) + + combo_box = builder.get_object("plot_type") + self.change_plot_type(combo_box) + builder.connect_signals(self) + + def input_updated(self, *args, **kargs): + self.battery_resistance = self.spin_bat_res.get_value() + + self.motors[0] = self.motor_widget.motor + To = self.spin_nominal_T.get_value() + fvar = self.spin_flux_var.get_value() / 100.0 + Rvar = self.spin_R_var.get_value() / 100.0 + Tr = self.spin_rotor.get_value() + Ts = self.spin_stator.get_value() + self.motors[1].update_constants( + n = 2 * self.motors[0].np, + R = self.motors[0].R * (1 + Rvar * (Ts - To)), + L = self.motors[0].L, + ke = self.motors[0].ke * (1 - fvar * (Tr - To)), + iq_max = self.motors[0].iq_max, + iq_nominal = self.motors[0].iq_nominal, + U = self.motors[0].U, + reduction_ratio = self.motors[0].rho) + + for i in range(2): + text = "\n".join([d[2](self.motors[i]) for d in DISPLAYED_CTES]) + self.label_specs[i].set_text(text) + + self.plot_need_update() + + def update_plot(self): + # Use this for scaling & legend + plot_caracteristics(self.mpl_fig.gca(), [self.motors[1]], ["C2"]) + # Next replot the curves in the right order + plot_motor_caracteristic(self.mpl_fig.gca(), self.motors[0], "k") + plot_motor_caracteristic(self.mpl_fig.gca(), self.motors[1], "C2") + + mot = self.motors[1] + + max_plot_speed = min(2 * mot.w_max_no_load, mot.compute_max_speed_deflux(0.0)) + w = np.linspace(0, max_plot_speed, 200) + + tau = np.linspace(0, mot.tau_max, 200) + w_grid, tau_grid = np.meshgrid(w, tau) + + plot_surface = np.full(w_grid.shape, -np.inf) + + # Select plot content + if self.plot_type[0] == "meca": + plot_func = lambda t, w: t * w + elif self.plot_type[0] == "thermal": + plot_func = lambda t, w: mot.compute_thermal_power(t, w) + elif self.plot_type[0] == "power": + plot_func = lambda t, w: t * w + mot.compute_thermal_power(t, w) + elif self.plot_type[0] == "efficiency": + plot_func = lambda t, w: t * w / (t * w + mot.compute_thermal_power(t, w)) * 100 + elif self.plot_type[0] == "battery": + plot_func = lambda t, w: get_battery_state(mot.U, self.battery_resistance,t * w + mot.compute_thermal_power(t, w))[0] + + for i in range(len(w_grid)): + mask = w_grid[i] <= mot.compute_max_speed_deflux(tau_grid[i]) + plot_surface[i][mask] = plot_func(tau_grid[i][mask], w_grid[i][mask]) + ax = self.mpl_fig.gca() + cm = mcolormaps.get_cmap("RdBu") + cm = cm.reversed() + cm.set_over('w') + cm.set_under('#A0A0A0') + ax.grid(False) + q = ax.pcolormesh(w_grid, tau_grid, plot_surface, cmap=cm, shading='gouraud', rasterized=True) + # Hide cursor data, the interpolation makes it wrong anyway. + q.get_cursor_data = lambda event: None + + def format_coord(w, t): + tail = "" + if w < mot.compute_max_speed_deflux(t): + tail = f", {self.plot_type[1]}: {plot_func(t, w):.1f}{self.plot_type[2]}" + return f"Velocity: {w:.1f}rad/s ({w * 30 / np.pi:.1f}rpm), Torque: {t:.1f}Nm" + tail + ax.format_coord = format_coord + + ax = self.cbar.ax + ax.clear() + self.cbar = self.mpl_fig.colorbar(mpl.cm.ScalarMappable(mpl.colors.Normalize(vmin=np.nanmin(plot_surface[plot_surface != -np.inf]), vmax=np.nanmax(plot_surface)), cmap=cm), cax = ax) + ax.set_ylabel(self.plot_type[1]) + if self.plot_type[0] == "battery": + sa = ax.secondary_yaxis(0.0, + functions=(lambda u : (mot.U - u) / self.battery_resistance, lambda i : mot.U - self.battery_resistance * i)) + sa.set_ylabel('Battery current (A)', fontsize=10) + + self.mpl_fig.canvas.draw() + + def change_plot_type(self, combo_box): + """ + Change the type of plot asked for + """ + model = combo_box.get_model() + self.plot_type = model[combo_box.get_active_iter()][1:] + self.spin_bat_res.set_sensitive(self.plot_type[0] == "battery") + self.plot_need_update() \ No newline at end of file diff --git a/src/nemo_bldc/gui/main_window.py b/src/nemo_bldc/gui/main_window.py new file mode 100644 index 0000000..80dfbcf --- /dev/null +++ b/src/nemo_bldc/gui/main_window.py @@ -0,0 +1,111 @@ +import typing as tp +from pathlib import Path +import numpy as np +import subprocess +import sys +import os + +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk, Gdk + +from ..ressources import get_ressource_path, load_motor_library +from ..doc import get_doc_path + +class MainWindow: + ''' + GUI tab: compare two motors + ''' + def __init__(self): + builder = Gtk.Builder() + builder.add_from_file(get_ressource_path("main_window.glade")) + self.window = builder.get_object("window") + self.window = builder.get_object("window") + # self.window. + self.notebook = builder.get_object("notebook") + self.tabs = [] + self.switch_external_window = builder.get_object("switch_external_window") + self.external_window = Gtk.Window() + self.external_window.set_title("Nemo - External viewer") + self.external_window.set_deletable(False) + self.external_window.set_size_request(600, 800) + self.external_window.connect("window-state-event", self.minimize_external_window) + self.current_tab = None + self.is_using_external_window = False + builder.connect_signals(self) + + def add_tab(self, tab: "AbstractTab"): + self.tabs.append(tab) + self.notebook.append_page(tab.tab_widget, Gtk.Label(label=tab.name)) + + def library_updated(self, fc_button): + new_lib = load_motor_library(Path(fc_button.get_filename())) + for tab in self.tabs: + tab.update_library(new_lib) + + def _pdf_viewer_not_available(self, path): + dialog = Gtk.Dialog(parent=self.window) + dialog.add_button("Ok", 0) + lab = Gtk.Label() + lab.set_markup("Oh no - PDF files cannot be opened in docker :( You can find this doc at:") + dialog.vbox.add(lab) + + ent = Gtk.Entry() + ent.set_text(path) + ent.set_editable(False) + dialog.vbox.add(ent) + dialog.vbox.set_spacing(10) + + dialog.set_size_request(800, -1) + dialog.show_all() + dialog.run() + dialog.destroy() + + def show_math_doc(self, *args): + if sys.platform == 'linux': + try: + subprocess.call(["xdg-open", get_doc_path("BrushlessMotorPhysics.pdf")]) + except FileNotFoundError: + self._pdf_viewer_not_available(get_doc_path("BrushlessMotorPhysics.pdf")) + else: + os.startfile(get_doc_path("BrushlessMotorPhysics.pdf")) + + def show_user_manual(self, *args): + if sys.platform == 'linux': + try: + subprocess.call(["xdg-open", get_doc_path("user_manual.pdf")]) + except FileNotFoundError: + self._pdf_viewer_not_available(get_doc_path("user_manual.pdf")) + else: + os.startfile(get_doc_path("user_manual.pdf")) + + def switch_plot_window(self, widget, state): + self.is_using_external_window = state + if self.is_using_external_window: + self.move_current_tab_plot_to_window() + self.external_window.show_all() + else: + self.return_plot_to_current_tab() + self.external_window.hide() + + def move_current_tab_plot_to_window(self): + self.current_tab.tab_widget.remove(self.current_tab.scroll_widget) + self.external_window.add(self.current_tab.scroll_widget) + + def return_plot_to_current_tab(self): + self.external_window.remove(self.current_tab.scroll_widget) + self.current_tab.tab_widget.pack2(self.current_tab.scroll_widget, True, False) + + def change_tab(self, notebook, page, page_num): + # If using external window, rearrange widgets + if self.is_using_external_window: + self.return_plot_to_current_tab() + self.current_tab = self.tabs[page_num] + self.move_current_tab_plot_to_window() + self.current_tab = self.tabs[page_num] + + def minimize_external_window(self, widget, event): + if self.is_using_external_window: + if event.new_window_state & Gdk.WindowState.ICONIFIED > 0: + self.switch_external_window.set_state(False) + self.external_window.deiconify() diff --git a/src/nemo_bldc/gui/motor_creation_helper.py b/src/nemo_bldc/gui/motor_creation_helper.py new file mode 100644 index 0000000..eaee1bc --- /dev/null +++ b/src/nemo_bldc/gui/motor_creation_helper.py @@ -0,0 +1,172 @@ +import typing as tp +import numpy as np +import copy + +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk, GObject + +from ..physics.motor import Motor +from ..ressources import get_ressource_path +from .utils import DISPLAYED_CTES + +class MotorCreationHelper(GObject.Object): + ''' + A widget for defining a motor from input parameters + ''' + @GObject.Signal + def motor_updated(self): + pass + + @GObject.Signal + def motor_name_updated(self): + pass + + def __init__(self, motor): + GObject.GObject.__init__(self) + builder = Gtk.Builder() + builder.add_from_file(get_ressource_path("motor_creation_helper.glade")) + self.dialog = builder.get_object("dialog") + self.dialog.set_title("Nemo - Motor creation helper") + + self.motor = Motor(1, 0.01, 0.0001, 1.0, 1.0, 1.0, 1.0, 1.0) + + self.toggle_winding = builder.get_object("toggle_winding") + self.toggle_RL = builder.get_object("toggle_RL") + self.toggle_current = builder.get_object("toggle_current") + self.toggle_poles = builder.get_object("toggle_poles") + + self.box_mag = builder.get_object("box_mag") + self.toggle_ke = builder.get_object("toggle_ke") + self.toggle_kv = builder.get_object("toggle_kv") + self.box_ke = builder.get_object("box_ke") + self.box_kv = builder.get_object("box_kv") + self.box_kt = builder.get_object("box_kt") + + self.toggle_ke_sp = builder.get_object("toggle_ke_sp") + self.toggle_ke_pp = builder.get_object("toggle_ke_pp") + self.toggle_ke_sp_rpm = builder.get_object("toggle_ke_sp_rpm") + self.toggle_ke_pp_rpm = builder.get_object("toggle_ke_pp_rpm") + self.toggle_kt_A = builder.get_object("toggle_kt_A") + + self.grid_labels = builder.get_object("grid_labels") + self.ctes_labels = [] + for i, (field, unit, _) in enumerate(DISPLAYED_CTES): + self.grid_labels.insert_row(2 * i + 1) + self.grid_labels.attach(Gtk.Label(label=field), 0, 2 * i + 1, 1 ,1) + self.grid_labels.attach(Gtk.Label(label=unit), 1, 2 * i + 1, 1, 1) + self.grid_labels.insert_row(2 * i + 2) + self.grid_labels.attach(Gtk.Separator(), 0, 2 * i + 2, 3, 1) + l = Gtk.Label("") + self.grid_labels.attach(l, 2, 2 * i + 1, 1, 1) + self.ctes_labels.append(l) + + self.spin_R = builder.get_object("spin_R") + self.spin_L = builder.get_object("spin_L") + self.spin_ke = builder.get_object("spin_ke") + self.spin_Inom = builder.get_object("spin_Inom") + self.spin_Imax = builder.get_object("spin_Imax") + self.spin_np = builder.get_object("spin_np") + self.spin_U = builder.get_object("spin_U") + + self.spin_R.set_value(motor.R) + self.spin_L.set_value(1000. * motor.L) + self.spin_ke.set_value(motor.ke) + self.spin_Inom.set_value(motor.iq_nominal) + self.spin_Imax.set_value(motor.iq_max) + self.spin_np.set_value(motor.np) + self.spin_U.set_value(motor.U) + + self.label_R = builder.get_object("label_R") + self.label_L = builder.get_object("label_L") + self.label_ke = builder.get_object("label_ke") + self.label_np = builder.get_object("label_np") + self.label_inom = builder.get_object("label_inom") + self.label_imax = builder.get_object("label_imax") + + builder.connect_signals(self) + self.input_updated() + + def input_updated(self, *args, **kargs): + ''' + Miscellaneous user input change: update motor and refresh display. + ''' + is_delta = not self.toggle_winding.get_active() + + R = self.spin_R.get_value() + L = self.spin_L.get_value() / 1000.0 + + # Convert to single phase, star + is_phase_to_phase = not self.toggle_RL.get_active() + coeff = 0.5 if is_phase_to_phase else (1/3 if is_delta else 1) + R *= coeff + L *= coeff + + # Magnetic parameter + val = self.spin_ke.get_value() + if self.toggle_ke.get_active(): + if self.toggle_ke_sp.get_active(): + ke = val * 1/np.sqrt(3) if is_delta else 1 + elif self.toggle_ke_pp.get_active(): + ke = val / np.sqrt(3) + elif self.toggle_ke_sp_rpm.get_active(): + ke = val * 30 / np.pi * 1/np.sqrt(3) if is_delta else 1 + else: + ke = val * 30 / np.pi / np.sqrt(3) + elif self.toggle_kv.get_active(): + ke = 1 / val * 30 / np.pi / np.sqrt(3) + else: + ktq = val * (1 if self.toggle_kt_A.get_active() else 1 / np.sqrt(2)) + ke = 2.0 / 3.0 * ktq + + npoles = int(self.spin_np.get_value()) + if not self.toggle_poles.get_active(): + npoles *= 2 + + current_coeff = 1 if self.toggle_current.get_active() else np.sqrt(2) + + self.motor.update_constants( + n = npoles, + R = R, + L = L, + ke = ke, + iq_max = current_coeff * self.spin_Imax.get_value(), + iq_nominal = current_coeff * self.spin_Inom.get_value(), + U = self.spin_U.get_value()) + + # Update GUI + self.label_R.set_text(f"{self.motor.R:.4f}") + self.label_L.set_text(f"{1000 * self.motor.L:.4f}") + self.label_ke.set_text(f"{self.motor.ke:.4f}") + self.label_np.set_text(f"{int(2 * self.motor.np)}") + self.label_inom.set_text(f"{self.motor.iq_nominal:.2f}") + self.label_imax.set_text(f"{self.motor.iq_max:.2f}") + + for i, (_, _, f) in zip(self.ctes_labels, DISPLAYED_CTES): + i.set_text(f(self.motor)) + + def mag_updated(self, *args): + ''' + User updated the magnetic parameter choice (Ke, Kv, Kt) + ''' + if self.toggle_ke.get_active(): + act = self.box_ke + elif self.toggle_kv.get_active(): + act = self.box_kv + else: + act = self.box_kt + self.box_mag.remove(self.box_mag.get_children()[-1]) + self.box_mag.pack_end(act, True, True, 0) + self.input_updated() + + def run(self): + """ + Run the motor creation helper ; return the new motor, or None + if the user canceled. + """ + self.dialog.show_all() + result = self.dialog.run() + self.dialog.hide() + if result == int(Gtk.ResponseType.OK): + return self.motor + return None \ No newline at end of file diff --git a/src/nemo_bldc/gui/utils.py b/src/nemo_bldc/gui/utils.py new file mode 100644 index 0000000..d5ae8d9 --- /dev/null +++ b/src/nemo_bldc/gui/utils.py @@ -0,0 +1,123 @@ +import matplotlib.colors as mc +import colorsys +import numpy as np +import functools +import json + +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gdk, GdkPixbuf + +from ..physics.motor import Motor + +DISPLAYED_CTES = [('Km, articular', 'Nm/\u221AW', lambda m: f"{m.K_m_art:.3f}"), + ('', 'W /Nm\u00B2', lambda m: f"{1 / m.K_m_art**2:.3f}"), + ('Power (non defluxing)', 'W', lambda m: f"{m.w_max_at_max_torque * m.iq_max * m.kt_q_art:.1f}"), + ('Ke phase-phase', 'V/rad.s', lambda m: f"{m.ke_phasetophase:.3f}"), + ('', 'V/krpm', lambda m: f"{m.ke_phasetophase * 1000 * np.pi / 30:.3f}"), + ('KV', 'rpm/V', lambda m: f"{1 / (m.ke_phasetophase * np.pi / 30):.0f}"), + ('Ktq, articular', 'Nm/A', lambda m: f"{m.kt_q_art:.3f}"), + ('Max RMS current', 'A', lambda m: f"{m.i_rms_max:.1f}"), + ('Max torque, articular', 'Nm', lambda m: f"{m.tau_max:.1f}"), + ('No load speed, articular', 'rad/s', lambda m: f"{m.w_max_no_load:.1f}"), + ('', 'rpm', lambda m: f"{30 / np.pi * m.w_max_no_load:.0f}"), + ('Max speed @ max torque', 'rad/s', lambda m: f"{m.w_max_at_max_torque:.1f}"), + ('', 'rpm', lambda m: f"{30 / np.pi * m.w_max_at_max_torque:.0f}")] + +def gdk_rgba_to_tuple(color: Gdk.RGBA): + return (color.red, color.green, color.blue, color.alpha) + +def mpl_to_gdk_rgba(color): + return Gdk.RGBA(*mc.to_rgba(color)) + +def color_to_pixbuf(color: Gdk.RGBA): + pixbuf = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, False, 8, 50, 25) + fillr = int(color.red * 255) << 24 + fillg = int(color.green * 255) << 16 + fillb = int(color.blue * 255) << 8 + fillcolor = fillr | fillg | fillb | 255 + pixbuf.fill(fillcolor) + return pixbuf + +def lighten_color(color, amount=0.5): + """ + Lightens the given color by multiplying (1-luminosity) by the given amount. + Input can be matplotlib color string, hex string, or RGB tuple. + + Examples: + >> lighten_color('g', 0.3) + >> lighten_color('#F034A3', 0.6) + >> lighten_color((.3,.55,.1), 0.5) + """ + try: + c = mc.cnames[color] + except: + c = color + c = colorsys.rgb_to_hls(*mc.to_rgb(c)) + return colorsys.hls_to_rgb(c[0], 1 - amount * (1 - c[1]), c[2]) + +def plot_motor_caracteristic(ax: "matplotlib.axis", motor, color, four_quadrants=False, linewidth=2, linestyle='-'): + tau = np.arange(0, motor.tau_max, 0.01) + w_no_deflux = np.array([motor.compute_max_speed_no_deflux(t) for t in tau] + [motor.w_max_at_max_torque, 0]) + ax.plot(w_no_deflux, list(tau) + [motor.tau_max, motor.tau_max], color=color, linewidth=linewidth, linestyle=linestyle) + # Defluxing + w_deflux = np.array([motor.compute_max_speed_deflux(t) for t in tau] + [motor.w_max_at_max_torque]) + ax.plot(w_deflux, list(tau) + [motor.tau_max], color=lighten_color(color), linewidth=linewidth, linestyle=linestyle) + # 4 quandrants. + if four_quadrants: + ax.plot(-w_deflux, tau, color=lighten_color(color), linewidth=linewidth, linestyle=linestyle) + ax.plot(w_deflux, -tau, color=lighten_color(color), linewidth=linewidth, linestyle=linestyle) + ax.plot(-w_deflux, -tau, color=lighten_color(color), linewidth=linewidth, linestyle=linestyle) + + ax.plot(w_no_deflux, list(-tau) + [-motor.tau_max, -motor.tau_max], color=color, linewidth=linewidth, linestyle=linestyle) + ax.plot(-w_no_deflux, list(tau) + [motor.tau_max, motor.tau_max], color=color, linewidth=linewidth, linestyle=linestyle) + ax.plot(-w_no_deflux, list(-tau) + [-motor.tau_max, -motor.tau_max], color=color, linewidth=linewidth, linestyle=linestyle) + + +def plot_caracteristics(ax, + motors, + colors, + margin=0, + four_quadrants=False, + plot_nominal=True, + secondary_y_axes=True): + ax.clear() + + for m, c in zip(motors, colors): + plot_motor_caracteristic(ax, m, c, four_quadrants) + if plot_nominal: + m_nom = Motor(1, 1, 1, 1, 1, 1, 48, 1) + m_nom.copy(m) + m_nom.update_constants(iq_max = m_nom.iq_nominal) + plot_motor_caracteristic(ax, m_nom, c, four_quadrants, 2, 'dotted') + + # Adjust range + if len(motors) > 0: + i_m = max([m.tau_max for m in motors]) + w_m = max([m.w_max_no_load for m in motors]) + if four_quadrants: + ax.set_xlim([-2 * w_m, 2 * w_m]) + else: + ax.set_xlim([-margin * w_m, 2 * w_m]) + ax.set_xlabel("Articular speed (top rad/s, bottom rpm)") + ax.secondary_xaxis(-0.07, functions=(lambda x: x * 30 / np.pi, lambda x: x * np.pi / 30)) + + if four_quadrants: + ax.set_ylim([-1.1 * i_m, 1.1 * i_m]) + else: + ax.set_ylim([-margin * i_m, 1.1 * i_m]) + ax.set_ylabel("Torque (Nm)") + # Create axes + if secondary_y_axes: + if len(motors) == 1: + ax.text(-0.1, 0.4, 'Quadrature current (A)', transform=ax.transAxes, rotation=90) + else: + ax.text(-0.15, -0.03, 'Quadrature current (A)', transform=ax.transAxes) + for i, m in enumerate(motors): + def direct(x, m): + return x / m.kt_q_art + def inv(x, m): + return x * m.kt_q_art + sa = ax.secondary_yaxis(-0.06 -0.04 * i , functions=(functools.partial(direct, m=m), functools.partial(inv, m=m)), color=colors[i]) + ax.grid(True) + diff --git a/src/nemo_bldc/gui/widget_motor_creation.py b/src/nemo_bldc/gui/widget_motor_creation.py new file mode 100644 index 0000000..a675666 --- /dev/null +++ b/src/nemo_bldc/gui/widget_motor_creation.py @@ -0,0 +1,135 @@ +import typing as tp +import numpy as np +import copy + +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk, GObject, Gdk + +import matplotlib.colors as mcolors +from ..physics.motor import Motor + +from ..ressources import get_ressource_path + +from .motor_creation_helper import MotorCreationHelper + +class DisplayMotor(Motor): + @staticmethod + def FromJson(data): + motor = Motor.FromDict(data) + return DisplayMotor(motor, data["name"], data["color"]) + + def __init__(self, motor, name, color): + super().__init__(1, 1, 1, 1, 1, 1, 48, 1) + super().copy(motor) + + self.name = name + self.color = color + + def get_gtk_color(self): + return Gdk.RGBA(*mcolors.to_rgba(self.color)) + + def to_json(self): + d = self.to_dict() + d["name"] = self.name + d["color"] = self.color + return d + +class MotorCreationWidget(GObject.Object): + ''' + A widget for defining a motor from input parameters + ''' + @GObject.Signal + def motor_updated(self): + pass + + @GObject.Signal + def motor_name_updated(self): + pass + + def __init__(self): + GObject.GObject.__init__(self) + builder = Gtk.Builder() + builder.add_from_file(get_ressource_path("motor_creation_widget.glade")) + self.top_frame = builder.get_object("top_frame") + + self.label_header = builder.get_object("header") + + self.entry_name = builder.get_object("entry_name") + self.spin_R = builder.get_object("spin_R") + self.spin_L = builder.get_object("spin_L") + self.spin_ke = builder.get_object("spin_ke") + self.spin_I = builder.get_object("spin_I") + self.spin_np = builder.get_object("spin_np") + self.spin_U = builder.get_object("spin_U") + self.spin_rho = builder.get_object("spin_rho") + self.spin_iqnom = builder.get_object("spin_iqnom") + + self.motor_box = builder.get_object("motor_box") + self.motor_list = builder.get_object("motor_list") + self.set_motor(DisplayMotor(Motor(1, 0.01, 0.0001, 1.0, 1.0, 1.0, 1.0, 1.0), + "Name", + "C0")) + + self.parent_callback = None + builder.connect_signals(self) + + def set_motor(self, motor: DisplayMotor): + self.motor = copy.copy(motor) + self.entry_name.set_text(motor.name) + self.spin_R.set_value(motor.R) + self.spin_L.set_value(1000. * motor.L) + self.spin_ke.set_value(motor.ke) + self.spin_I.set_value(motor.iq_max) + self.spin_iqnom.set_value(motor.iq_nominal) + self.spin_iqnom.set_value(motor.iq_nominal) + self.spin_np.set_value(2 * motor.np) + self.spin_U.set_value(motor.U) + self.spin_rho.set_value(motor.rho) + self.motor_box.set_active(-1) + self.input_updated() + + def input_updated(self, *args, **kargs): + self.motor.update_constants( + n = int(self.spin_np.get_value()), + R = self.spin_R.get_value(), + L = self.spin_L.get_value() / 1000.0, + ke = self.spin_ke.get_value(), + iq_max = self.spin_I.get_value(), + iq_nominal = self.spin_iqnom.get_value(), + U = self.spin_U.get_value(), + reduction_ratio = self.spin_rho.get_value()) + self.emit("motor_updated") + + def name_updated(self, *args): + self.motor.name = self.entry_name.get_text() + self.emit("motor_name_updated") + + def box_updated(self, combo: tp.Any): + tree_iter = combo.get_active_iter() + if tree_iter is None: + return + name = combo.get_model()[tree_iter][0] + + self.motor.copy(self.motor_library[name]) + self.motor.name = name + + self.set_motor(self.motor) + self.input_updated() + + def update_library(self, library): + ''' + Update motor library. + ''' + self.motor_library = library + self.motor_list.clear() + for n in self.motor_library: + self.motor_list.append([n]) + self.motor_box.set_active(0) + + def ask_for_helper(self, button): + helper = MotorCreationHelper(self.motor) + result = helper.run() + if result is not None: + self.set_motor(DisplayMotor(result, self.motor.name, self.motor.color)) + diff --git a/src/nemo_bldc/nemo.py b/src/nemo_bldc/nemo.py new file mode 100644 index 0000000..c4964df --- /dev/null +++ b/src/nemo_bldc/nemo.py @@ -0,0 +1,36 @@ +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +from .gui.gui_compare_motors import CompareMotors +from .gui.gui_single_motor_perf import SingleMotorPerfTab +from .gui.main_window import MainWindow + +from .ressources import DEFAULT_LIBRARY +import pkg_resources + +def nemo_main(is_unit_test = False): + main = MainWindow() + main.window.connect("delete-event", Gtk.main_quit) + main.window.set_default_size(400, 300) + + c_tab = CompareMotors() + main.add_tab(c_tab) + main.add_tab(SingleMotorPerfTab()) + for tab in main.tabs: + tab.update_library(DEFAULT_LIBRARY) + c_tab.add_motor() + + version = pkg_resources.require("nemo_bldc")[0].version + main.window.set_title(f"Nemo - {version}") + main.window.show_all() + if is_unit_test: + for t in main.tabs: + t.update_plot() + else: + Gtk.main() + +if __name__ == "__main__": + nemo_main() + + diff --git a/src/nemo_bldc/physics/__init__.py b/src/nemo_bldc/physics/__init__.py new file mode 100644 index 0000000..28b3c09 --- /dev/null +++ b/src/nemo_bldc/physics/__init__.py @@ -0,0 +1,2 @@ +from .motor import Motor +from .battery import get_battery_state \ No newline at end of file diff --git a/src/nemo_bldc/physics/battery.py b/src/nemo_bldc/physics/battery.py new file mode 100644 index 0000000..f25ca49 --- /dev/null +++ b/src/nemo_bldc/physics/battery.py @@ -0,0 +1,16 @@ +# Modeling of a battery as voltage source + resistor +import typing as tp +import numpy as np + +def get_battery_state(U_bat, R_bat, P): + ''' + Return battery current and voltage, given a power input. + The battery is modeled as a constant voltage source U_bat (typ. 48V), with + a resistor R_bat in serie. + - U_bat: battery voltage + - R_bat: battery resistor + - P: power drawn + Return: U, I + ''' + I = (U_bat - np.sqrt(U_bat**2 - 4 * R_bat * P)) / 2 / R_bat + return U_bat - R_bat * I, I diff --git a/src/nemo_bldc/physics/motor.py b/src/nemo_bldc/physics/motor.py new file mode 100644 index 0000000..9da30f0 --- /dev/null +++ b/src/nemo_bldc/physics/motor.py @@ -0,0 +1,200 @@ +import typing as tp +import numpy as np + +class Motor: + ''' + A class to represent a PMSM motor, implementing all relevant computations + ''' + @staticmethod + def FromDict(data): + m = Motor(1, 1, 1, 1, 1, 1, 48, 1) + m.update_constants(n = data['np'], + R = data['R'], + L = data['L'] / 1000.0, + ke = data['ke'], + iq_max = data['i_quadrature_max'], + iq_nominal = data.get('i_quadrature_nominal', data['i_quadrature_max']), + U = data['U'], + reduction_ratio = data['reduction_ratio']) + return m + + def __init__(self, n:int, R:float, L:float, ke:float, iq_max:float, iq_nominal:float, U: float, reduction_ratio:float): + ''' + Build a PMSM motor from the fundamental parameters. + - n: number of poles + - R: resistor, per phase, in Ohm + - L: phase inductance, in H + - ke: rotor flux in a phase + - iq_max: maximum quadrature current (assuming no defluxing) in the motor + - U: driver voltage + - reduction_ratio: reduction ratio + Note: + - magnetic saturations not modelled + - salliancy ratio is set to 1 + ''' + self.update_constants(n, R, L, ke, iq_max, iq_nominal, U, reduction_ratio) + + def to_dict(self): + ''' + Store motore in dictionnary to save to json file. + ''' + return {"np": 2 * self.np, + "R": self.R, + "L": self.L, + "L": 1000.0 * self.L, + "ke": self.ke, + "i_quadrature_max": self.iq_max, + "i_quadrature_nominal": self.iq_nominal, + "U": self.U, + "reduction_ratio": self.rho + } + + def copy(self, other_motor:"Motor"): + ''' + Copy the constants of other_motor onto self. + ''' + self.update_constants(n = 2 * other_motor.np, + R = other_motor.R, + L = other_motor.L, + ke = other_motor.ke, + iq_max = other_motor.iq_max, + iq_nominal = other_motor.iq_nominal, + U = other_motor.U, + reduction_ratio = other_motor.rho) + + def update_constants(self, + n:int = None, + R:float = None, + L:float = None, + ke:float = None, + iq_max:float = None, + iq_nominal:float = None, + U: float = None, + reduction_ratio: float = None): + ''' + Update the motor's constants (none to keep previous value). + Note that updating the parameters should be done through this function + only, otherwise the derived constants will be wrong ! + The parameters are the same as in the constructor. + ''' + if n is not None: + self.np = n / 2 + if R is not None: + self.R = R + if L is not None: + self.L = L + if ke is not None: + self.ke = ke + if iq_max is not None: + self.iq_max = iq_max + if iq_nominal is not None: + self.iq_nominal = iq_nominal + if U is not None: + self.U = U + if reduction_ratio is not None: + self.rho = reduction_ratio + + self._compute_derived_constants() + + def _compute_derived_constants(self): + ''' + Compute useful constants, derived from the fundamental parameters + ''' + # See BrushlessMotorPhysics.pdf for more informations on these constants. + # Torque-related constants + self.kt_q_art = 3.0 / 2.0 * self.rho * self.ke + self.i_rms_max = self.iq_max / np.sqrt(2) + self.tau_max = self.kt_q_art * self.iq_max + + # Velocity-related constants + self.ke_phasetophase = np.sqrt(3) * self.ke + self.w_max_no_load = self.U / self.ke_phasetophase / self.rho + self.w_max_at_max_torque = self.compute_max_speed_no_deflux(self.tau_max) + + # Power-related constants + self.K_m_art = np.sqrt(2.0 / 3.0) * self.kt_q_art / np.sqrt(self.R) + + # Miscellaneous. + self.r_deflux = self.np * self.L * self.iq_max / self.ke + tau_n = self.kt_q_art * self.iq_nominal + self.nominal_power = self.compute_max_speed_no_deflux(tau_n) * tau_n + + + def __str__(self): + return f"R: {self.R}Ohm, L: {self.L * 1000.0}mH, Phi: {self.ke}Wb, Iq_max: {self.iq_max}A, Np {self.np}, U {self.U}, reduction {self.rho}" + + def compute_max_speed_no_deflux(self, tau): + ''' + Return the maximum articular speed, given articular torque, when not defluxing. + - tau: input articular torque, Nm + ''' + tau = np.asarray(tau) + + iq = tau / self.kt_q_art + + a = self.rho**2 * ((self.np * self.L * iq)**2 + self.ke**2) + b = 2 * self.rho * self.R * self.ke * iq + c = (self.R * iq)**2 - self.U**2 / 3 + + return (-b + np.sqrt(b**2 - 4 * a * c)) / 2 / a + + def compute_defluxing_current(self, tau, w): + ''' + Get defluxing current, in A, given articular torque and velocity. + ''' + tau = np.asarray(tau) + w = np.asarray(w) + + i_q = tau / self.kt_q_art + + # Compute the minimum amount of current that is needed to deflux: + # this corresponds to the amount of current such that U = U_eff. + a = self.R**2 + (self.rho * w * self.np * self.L)**2 + b = 2 * self.np * self.L * self.ke * (self.rho * w)**2 + c = (self.rho * w * self.np * self.L * i_q)**2 \ + + 2 * self.R * i_q * self.ke * self.rho * w \ + + self.R**2 * i_q**2 + (self.ke * self.rho * w)**2 \ + - self.U**2 / 3 + + return np.minimum(0.0, (-b + np.sqrt(b**2 - 4 * a * c)) / 2 / a) + + def compute_max_speed_deflux(self, tau): + ''' + Returns the maximum articular speed, given articular torque, with defluxing. + - tau: input articular torque, Nm + ''' + tau = np.asarray(tau) + + i_q = tau / self.kt_q_art + i_d = np.maximum(-np.sqrt(np.maximum(0, 2 * self.i_rms_max**2 - i_q**2)), + - self.ke / self.np / self.L) + + a = (self.rho * self.np * self.L * i_q)**2 + self.rho**2 * (self.np * self.L * i_d + self.ke)**2 + b = 2 * self.rho * self.R * i_q * self.ke + c = self.R**2 * (i_d**2 + i_q**2) - self.U**2 / 3 + return np.maximum((-b + np.sqrt(b**2 - 4 * a * c)) / 2 / a, self.compute_max_speed_no_deflux(tau)) + + def compute_thermal_power(self, tau, w, force_no_defluxing=False): + ''' + Compute the thermal power to reach a specific working point. + The motor will deflux if needed, unless specified. + Note: this function does not check that the point is feasible for + the motor: if you ask for infinite torque, you get infinite power ! + ''' + tau = np.asarray(tau) + w = np.asarray(w) + + i_q = tau / self.kt_q_art + if force_no_defluxing: + i_d = np.zeros(w.shape) + else: + i_d = self.compute_defluxing_current(tau, w) + power = 3 / 2 * self.R * (i_d**2 + i_q**2) + return power + + def get_power(self, w, tau): + ''' + Return the total power (mecanical + thermal) required by the motor, + (assuming no defluxing). + ''' + return w * tau + 1 / self.K_m_art**2 * tau**2 \ No newline at end of file diff --git a/src/nemo_bldc/ressources/__init__.py b/src/nemo_bldc/ressources/__init__.py new file mode 100644 index 0000000..f0f7b24 --- /dev/null +++ b/src/nemo_bldc/ressources/__init__.py @@ -0,0 +1 @@ +from .utils import get_ressource_path, load_motor_library, DEFAULT_LIBRARY diff --git a/src/nemo_bldc/ressources/abstract_tab.glade b/src/nemo_bldc/ressources/abstract_tab.glade new file mode 100644 index 0000000..54b3004 --- /dev/null +++ b/src/nemo_bldc/ressources/abstract_tab.glade @@ -0,0 +1,29 @@ + + + + + + True + True + + + + + + 300 + True + True + True + True + in + + + + + + True + False + + + + diff --git a/src/nemo_bldc/ressources/compare_tab.glade b/src/nemo_bldc/ressources/compare_tab.glade new file mode 100644 index 0000000..d31dfe7 --- /dev/null +++ b/src/nemo_bldc/ressources/compare_tab.glade @@ -0,0 +1,284 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + 300 + True + False + 3 + 3 + 3 + 3 + vertical + + + 200 + 200 + True + True + in + + + True + False + + + True + True + motor_compare_list + False + False + False + True + + + + + + Color + + + + 0 + + + + + + + Motor + + + + 1 + + + + + + + Km (W/Nm2) + + + + 2 + + + + + + + Kt (Nm/A) + + + + 3 + + + + + + + Nominal power (W) + + + + 4 + + + + + + + + + + + False + True + 0 + + + + + True + False + 5 + + + Add + True + True + True + True + + + + False + True + 0 + + + + + Remove + True + True + True + True + + + + False + True + 1 + + + + + False + True + 1 + + + + + True + False + 0 + in + + + True + False + 12 + + + True + False + + + + True + False + 2 + 6 + + + True + False + Unit + + + + + + 1 + 0 + + + + + + + + + + + + + + False + True + 0 + + + + + True + False + vertical + + + False + True + 1 + + + + + 200 + 200 + True + True + True + in + + + True + False + + + + True + False + 2 + 5 + + + + + + + + + + True + True + 2 + + + + + + + + + True + False + Derived constants + + + + + True + True + 2 + + + + + + + diff --git a/src/nemo_bldc/ressources/main_window.glade b/src/nemo_bldc/ressources/main_window.glade new file mode 100644 index 0000000..d243765 --- /dev/null +++ b/src/nemo_bldc/ressources/main_window.glade @@ -0,0 +1,168 @@ + + + + + + + *.json + + + + True + False + + + True + False + Mathematical documentation + True + + + + + + True + False + User manual + True + + + + + + False + Motor Analyser + + + + True + False + 5 + 10 + 5 + + + True + False + start + 5 + Motor library file + + + 0 + 0 + + + + + True + False + start + filefilter1 + + + + + 1 + 0 + + + + + True + True + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + 5 + + + + + True + True + False + True + end + 5 + True + help_menu + + + True + False + start + 5 + 5 + Help + + + + + 4 + 0 + + + + + True + False + start + 5 + 5 + Plot in external window + + + 2 + 0 + + + + + True + True + start + center + + + + 3 + 0 + + + + + + + + *.tlmc + *.hdf5 + + + + + + + + + diff --git a/src/nemo_bldc/ressources/motor_creation_helper.glade b/src/nemo_bldc/ressources/motor_creation_helper.glade new file mode 100644 index 0000000..bfe13d7 --- /dev/null +++ b/src/nemo_bldc/ressources/motor_creation_helper.glade @@ -0,0 +1,1066 @@ + + + + + + 1000 + 1 + 10 + + + 1000 + 1 + 10 + + + 0.0001 + 100 + 0.01 + 10 + + + 0.0001 + 100 + 0.01 + 10 + + + 100 + 1 + 10 + + + True + False + vertical + 5 + + + Nm/A + True + True + False + True + True + + + + False + True + 0 + + + + + Nm/Arms + True + True + False + True + toggle_kt_A + + + False + True + 1 + + + + + True + False + vertical + 5 + + + rpm/V + True + True + False + True + True + + + False + True + 0 + + + + + 0.001 + 50000 + 0.001 + 0.01 + 0.10 + + + 1 + 100 + 1 + 1 + 10 + + + False + dialog + + + False + vertical + 2 + + + False + end + + + Cancel + True + True + True + + + True + True + 0 + + + + + Create + True + True + True + + + True + True + 1 + + + + + False + False + 0 + + + + + + True + False + 10 + 10 + 5 + 5 + + + True + False + Motor creation helper + + + + + + 0 + 0 + 3 + + + + + True + False + + + 1 + 1 + + + + + + True + False + 5 + 5 + + + True + False + 0 + in + + + True + False + 12 + + + + True + False + 5 + 5 + + + True + True + center + 0.000 + R + 3 + 0.0001 + + + + 1 + 0 + + + + + True + False + start + center + R (Ohm) + + + 0 + 0 + + + + + True + False + start + center + L (mH) + + + 0 + 1 + + + + + True + True + center + 0.000 + L + 3 + 0.0001 + + + + 1 + 1 + + + + + Terminal (aka. line to line +or phase to phase) + True + True + False + start + True + True + toggle_RL + + + 2 + 0 + + + + + Single phase + True + True + False + start + True + + + + 2 + 1 + + + + + + + + + True + False + 5 + Resistance and Inductance + + + + + 0 + 2 + 4 + + + + + Delta + True + True + False + start + True + True + toggle_winding + + + 3 + 1 + + + + + Star (wye) + True + True + False + start + True + True + + + + 2 + 1 + + + + + True + False + start + Connection type + + + 0 + 1 + 2 + + + + + True + False + 0 + in + + + True + False + 12 + + + True + False + 5 + + + True + False + center + vertical + 5 + + + Ke + True + True + False + True + True + + + + False + True + 0 + + + + + KV + True + True + False + True + True + toggle_ke + + + + False + True + 1 + + + + + Kt + True + True + False + True + True + toggle_ke + + + + False + True + 2 + + + + + False + True + 0 + + + + + True + True + center + 0.000 + k + 4 + 0.0001 + + + + False + True + 1 + + + + + True + False + vertical + 5 + + + V.s/rad, phase to phase + True + True + False + True + True + toggle_ke_sp + + + + False + True + 0 + + + + + V.s/rad, single phase + True + True + False + True + + + + False + True + 1 + + + + + V/rpm, phase to phase + True + True + False + True + toggle_ke_sp + + + False + True + 2 + + + + + V/rpm, single phase + True + True + False + True + toggle_ke_sp + + + + False + True + 3 + + + + + False + True + 2 + + + + + + + + + True + False + 5 + 5 + Magnetic field strength (choose one) + + + + + 0 + 3 + 4 + + + + + Arms + True + True + False + True + toggle_current + + + 3 + 4 + 2 + + + + + A + True + True + False + True + True + + + + 2 + 4 + 2 + + + + + True + False + start + Battery voltage (V) + + + 0 + 7 + + + + + True + True + 0.000 + U + 3 + 0.0001 + + + + 1 + 7 + + + + + poles + True + True + False + True + True + + + + 2 + 6 + + + + + pairs + True + True + False + True + toggle_poles + + + 3 + 6 + + + + + True + True + 0.000 + number + poles + 0.0001 + + + + 1 + 6 + + + + + True + False + start + Iq nominal + + + 0 + 4 + + + + + True + False + start + Iq max + + + 0 + 5 + + + + + True + False + start + Poles + + + 0 + 6 + + + + + True + True + 0.000 + I nom + 3 + 0.0001 + + + + 1 + 4 + + + + + True + True + 0.000 + Imax + 3 + 0.0001 + + + + 1 + 5 + + + + + True + False + Input + + + + + + + 0 + 0 + 4 + + + + + True + False + + + 0 + 8 + 4 + + + + + True + False + 5 + 0 + in + + + True + False + 12 + + + + True + False + 5 + 5 + + + True + False + start + R phase (Ohm) + + + 0 + 0 + + + + + True + False + start + True + var + + + 1 + 0 + + + + + True + False + start + L phase (mH) + + + 2 + 0 + + + + + True + False + start + True + var + + + 3 + 0 + + + + + True + False + start + Ke phase (V.s/rad) + + + 0 + 1 + + + + + True + False + start + True + var + + + 1 + 1 + + + + + True + False + start + N poles + + + 2 + 1 + + + + + True + False + start + True + var + + + 3 + 1 + + + + + True + False + start + Iq nominal (A) + + + 0 + 2 + + + + + True + False + start + Iq max (A) + + + 2 + 2 + + + + + True + False + start + True + var + + + 1 + 2 + + + + + True + False + start + True + var + + + 3 + 2 + + + + + + + + + True + False + 5 + 5 + Parameter summary: equivalent star motor + + + + + 0 + 9 + 4 + + + + + + + + + + + 0 + 1 + + + + + True + False + vertical + 5 + + + True + False + Verification + + + + + + + False + True + 0 + + + + + True + False + Check that the computed values are reasonably +close to the datasheet <small>(differences can exist due to +approximations in the datasheet, or because +Nemo neglects non-linearities that may affect +motor performance)</small>. + True + + + False + True + 1 + + + + + + True + False + 5 + 5 + 5 + + + True + False + Unit + + + + + + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + False + True + 2 + + + + + + + + 2 + 1 + + + + + False + True + 1 + + + + + + button1 + button2 + + + diff --git a/src/nemo_bldc/ressources/motor_creation_widget.glade b/src/nemo_bldc/ressources/motor_creation_widget.glade new file mode 100644 index 0000000..47b055d --- /dev/null +++ b/src/nemo_bldc/ressources/motor_creation_widget.glade @@ -0,0 +1,360 @@ + + + + + + 1 + 1000 + 0.10 + 10 + + + 0.0001 + 100 + 0.01 + 10 + + + 0.0001 + 100 + 0.01 + 10 + + + 6 + 1000 + 0.10 + 10 + + + + + + + + + 2 + 200 + 2 + 10 + + + 1000 + 0.10 + 10 + + + 100 + 0.01 + 10 + + + 1000 + 1 + 10 + + + True + False + 0 + in + + + True + False + 12 + + + + True + False + 3 + 3 + + + True + False + R (Ohm) + + + 0 + 1 + + + + + True + False + Iq max(A) + + + 0 + 3 + + + + + True + False + L (mH) + + + 2 + 1 + + + + + True + False + Ke (V.s/rad) + + + 0 + 2 + + + + + True + False + N poles + + + 2 + 2 + + + + + True + False + Reduction ratio + + + 0 + 4 + + + + + True + False + Name + + + 0 + 0 + + + + + True + True + 3 + 30 + Motor + number + + + + 1 + 0 + 3 + + + + + True + False + Reset from template + + + 0 + 5 + + + + + True + False + 3 + 3 + motor_list + + + + + 0 + + + + + 1 + 5 + 2 + + + + + True + True + spin_rho_adj + 1 + + + + 1 + 4 + + + + + True + False + U (V) + + + 2 + 4 + + + + + True + False + Iq nom(A) + + + 2 + 3 + + + + + True + True + 0 + spin_iqnom_adj + 1 + + + + 3 + 3 + + + + + True + True + 0,0 + U + 1 + + + + 3 + 4 + + + + + True + True + 6,0 + np + True + 2 + + + + 3 + 2 + + + + + True + True + 0,0 + L + 3 + + + + 3 + 1 + + + + + True + True + 0,0 + R + 3 + + + + 1 + 1 + + + + + True + True + 0,0 + spin_k + 4 + 1 + + + + 1 + 2 + + + + + True + True + 1,0 + I + 1 + 1 + + + + 1 + 3 + + + + + Helper + True + True + True + + + + 3 + 5 + + + + + + + + + True + False + 3 + Motor properties + + + + diff --git a/src/nemo_bldc/ressources/motor_library.json b/src/nemo_bldc/ressources/motor_library.json new file mode 100644 index 0000000..7bdb74f --- /dev/null +++ b/src/nemo_bldc/ressources/motor_library.json @@ -0,0 +1,68 @@ +{ + "MyActuator RMD-X6 V3": + { + "R": 0.275, + "L": 0.090, + "ke": 0.0900, + "i_quadrature_max": 10.8, + "i_quadrature_nominal": 3.6, + "np": 28, + "U": 48, + "reduction_ratio": 8 + }, + "MyActuator RMD-X6 V2": + { + "R": 0.165, + "L": 0.095, + "ke": 0.0919, + "i_quadrature_max": 12.0, + "i_quadrature_nominal": 4, + "np": 28, + "U": 24, + "reduction_ratio": 6 + }, + "Maxon EC45-70W (548270)": + { + "R": 0.304, + "L": 0.232, + "ke": 0.0246, + "i_quadrature_max": 39.5, + "i_quadrature_nominal": 3.2, + "np": 16, + "U": 24, + "reduction_ratio": 1 + }, + "Maxon EC60-100W (542002)": + { + "R": 0.153, + "L": 0.094, + "ke": 0.0356, + "i_quadrature_max": 78.2, + "i_quadrature_nominal": 5.47, + "np": 14, + "U": 24, + "reduction_ratio": 1 + }, + "MAD 5005": + { + "R": 0.084, + "L": 0.0575, + "ke": 0.0197, + "i_quadrature_max": 18.4, + "i_quadrature_nominal": 5.0, + "np": 28, + "U": 22, + "reduction_ratio": 1 + }, + "MyActuator RMD-L-7025": + { + "R": 0.140, + "L": 0.120, + "ke": 0.0698, + "i_quadrature_max": 15.0, + "i_quadrature_nominal": 8.3, + "np": 28, + "U": 24, + "reduction_ratio": 1 + } +} diff --git a/src/nemo_bldc/ressources/single_motor_tab.glade b/src/nemo_bldc/ressources/single_motor_tab.glade new file mode 100644 index 0000000..c4d2b32 --- /dev/null +++ b/src/nemo_bldc/ressources/single_motor_tab.glade @@ -0,0 +1,496 @@ + + + + + + -20 + 120 + 25 + 0.5 + 10 + + + -20 + 120 + 25 + 0.5 + 10 + + + 0.01 + 10 + 0.01 + 10 + + + 6 + 100 + 0.10 + 10 + + + 100 + 0.12 + 0.01 + 10 + + + 1 + 100 + 0.10 + 10 + + + + + + + + + + + + + + + Mechanical power + meca + Mechanical power (W) + W + + + Thermal power + thermal + Thermal power (W) + W + + + Total power + power + Total power (W) + W + + + Efficiency + efficiency + Efficiency (%) + % + + + Battery status + battery + Battery voltage (V) + V + + + + + 100 + 0.40 + 0.01 + 10 + + + 100 + 25 + 1 + 10 + + + True + False + 3 + 3 + 3 + 3 + vertical + 5 + + + + + + + True + False + 5 + 5 + + + True + False + Battery resistance (Ohm) + + + 0 + 1 + + + + + True + False + Plot type + + + 0 + 0 + + + + + True + False + plot_type_list + 0 + 0 + + + + + 0 + + + + + 1 + 0 + + + + + True + True + 25,0 + battery_res + 3 + 0.14999999999999999 + + + + 1 + 1 + + + + + False + True + 1 + + + + + True + False + 0 + in + + + True + False + 12 + + + + True + False + 2 + 2 + + + True + False + Rotor temperature + + + 0 + 0 + + + + + True + False + Stator temperature + + + 0 + 1 + + + + + True + False + Rotor flux variation (%/°C) + + + 0 + 2 + + + + + True + False + Stator resistor variation (%/°C) + + + 0 + 3 + + + + + True + False + Nominal temperature (°C) + + + 0 + 4 + + + + + True + True + 0,12 + flux + 2 + 0.12 + + + + 1 + 2 + + + + + True + True + 0,40 + res + 2 + 0.40 + + + + 1 + 3 + + + + + True + True + 25 + temp + 25 + + + + 1 + 4 + + + + + True + True + 25,0 + adj_rotor + 1 + 25 + + + + 1 + 0 + + + + + True + True + 25,0 + adj_stator + 1 + 25 + + + + 1 + 1 + + + + + + + + + True + False + Thermal state + + + + + False + True + 2 + + + + + True + False + 0 + in + + + True + False + 12 + + + + True + False + 2 + 2 + + + True + False + vertical + + + 2 + 0 + 2 + + + + + True + False + spec_legend + + + 0 + 1 + + + + + True + False + Heated specs + + + + + + 5 + 0 + + + + + True + False + second_spec + + + 5 + 1 + + + + + True + False + Nominal specs + + + + + + 3 + 0 + + + + + True + False + first_spec + + + 3 + 1 + + + + + True + False + Unit + + + + + + 1 + 0 + + + + + True + False + spec_unit + + + 1 + 1 + + + + + True + False + + + 4 + 0 + 2 + + + + + + + + + + + + True + False + Derived constants + + + + + False + True + 3 + + + + diff --git a/src/nemo_bldc/ressources/utils.py b/src/nemo_bldc/ressources/utils.py new file mode 100644 index 0000000..a02d4c7 --- /dev/null +++ b/src/nemo_bldc/ressources/utils.py @@ -0,0 +1,23 @@ +import pkg_resources +import json + +from ..physics.motor import Motor + +def get_ressource_path(filename:str): + return pkg_resources.resource_filename(__name__, filename) + +def load_motor_library(source_file: str): + ''' + Load a motor library json file. + ''' + library = {} + with open(source_file, "r") as f: + data = json.load(f) + for k, d in data.items(): + try: + library[k] = Motor.FromDict(d) + except KeyError: + print(f"Warning: failed to load {k}") + return library + +DEFAULT_LIBRARY = load_motor_library(get_ressource_path("motor_library.json")) \ No newline at end of file diff --git a/unit/test_motor.py b/unit/test_motor.py new file mode 100644 index 0000000..4f64e1d --- /dev/null +++ b/unit/test_motor.py @@ -0,0 +1,61 @@ +# Test the physical computation of Nemo, by comparing various values +# with the datasheets. +import pytest +import numpy as np +import copy + +from nemo_bldc.physics import Motor +from nemo_bldc.ressources import DEFAULT_LIBRARY + +def test_MyActuator(): + # Check the derived parameters against the datasheet, to make sure the + # computation makes sense. + m = DEFAULT_LIBRARY["MyActuator RMD-X6 V2"] + + # Check the parameters + assert 2 * m.R == pytest.approx(0.33) # phase to phase + assert 2 * m.L == pytest.approx(0.19*1e-3) # phase to phase + assert m.U == pytest.approx(24) + assert m.iq_nominal == pytest.approx(4) + assert m.np == 14 + assert m.rho == pytest.approx(6) + + # Magnetic parameter was determined using KV + kv = 1 / m.ke * 60 / 2 / np.pi / np.sqrt(3) + assert kv == pytest.approx(60.0, rel=0.001) + + # Speed limits + assert m.w_max_at_max_torque * 30 / np.pi == pytest.approx(190, rel=0.1) + assert m.w_max_no_load * 30 / np.pi == pytest.approx(240, rel=0.01) + assert m.compute_max_speed_no_deflux(m.kt_q_art *m.iq_nominal) > m.w_max_at_max_torque + assert m.compute_max_speed_no_deflux(m.kt_q_art *m.iq_nominal) < m.w_max_no_load + + +def test_reduction(): + # Make sure that the reduction ratio rho works as intended + m = DEFAULT_LIBRARY["MyActuator RMD-X6 V2"] + m.update_constants(reduction_ratio = 1.0) + a = copy.copy(DEFAULT_LIBRARY["MyActuator RMD-X6 V2"]) + rho = 42.2 + a.update_constants(reduction_ratio = rho) + + # Check the parameters + assert m.R == pytest.approx(a.R) + assert m.L == pytest.approx(a.L) + assert m.ke == pytest.approx(a.ke) + assert m.iq_max == pytest.approx(a.iq_max) + assert m.np == pytest.approx(a.np) + assert m.U == pytest.approx(a.U) + assert m.rho == pytest.approx(1) + assert a.rho == pytest.approx(rho) + + # Check the derived constants + assert a.kt_q_art == pytest.approx(rho * m.kt_q_art) + assert a.K_m_art == pytest.approx(rho * m.K_m_art) + assert a.w_max_no_load == pytest.approx(m.w_max_no_load / rho) + assert a.w_max_at_max_torque == pytest.approx(m.w_max_at_max_torque / rho) + # Maximum speed is not exactly linear in reduction ratio, due to the inductive effect. + assert a.compute_max_speed_no_deflux(0.1) == pytest.approx(m.compute_max_speed_no_deflux(0.1) / rho, 0.05) + + # Check conservation of power + assert m.nominal_power == pytest.approx(a.nominal_power) diff --git a/unit/test_nemo.py b/unit/test_nemo.py new file mode 100644 index 0000000..7e7d21a --- /dev/null +++ b/unit/test_nemo.py @@ -0,0 +1,5 @@ +from nemo_bldc.nemo import nemo_main + +def test_nemo_build_gui(): + # Simply test if GUI works ok + nemo_main(is_unit_test = True) \ No newline at end of file