Objektorienterad programmering: Projekt Kylsim

Utvecklat av Gunnar Joki, med modifieringar för Visual Studio 2005 av Thomas Padron-McCarthy (thomas.padron-mccarthy@tech.oru.se). Senaste ändring: 5 oktober 2007.

Den här bilden föreställer ett kyl- och reningssystem för vattnet i en reaktortank i ett kärnkraftverk:

En skiss över kylsystemet

(Klicka på bilden för att se den i större format.)

Det heta vattnet i tanken cirkuleras med hjälp av pumpen P1 från tanken genom värmeväxlaren VVXL1 och filtret F1. Normalt är ventilerna V1, V2 och V3 öppna och V4 och V5 stängda. Med tiden blir filtret igensatt, varvid flödet genom systemet minskar. Filtret spolas rent med ett backflöde. Genom att stänga ventilen V2 och öppna V4 och V5 styrs delar av flödet bakvägen genom filtret F1 och ut i avloppet nod N8. Höjdskillnader behöver ej beaktas.

Skriv ett program, Kylsim, som simulerar systemet. Vattenflödet i flödesvägarna och trycket i noderna ska simuleras. Manövrering av ventilerna, dvs öppna/stäng, ska simuleras. Manövrering av pumpen, dvs start/stopp, ska simuleras. Värmetransporten genom systemet ska inte simuleras, utan bara vattenflödet.

[5~Programmet ska innehålla ett grafiskt användargränssnitt (GUI, Graphical User Interface) med vilket man kan betrakta och manövrera systemet enligt (för betygsnivå 3):

Programmet ska skrivas i Microsoft Visual C++ och köras i Windows.

Redovisning

Programmet ska lämnas in i form av en rapport med försättsblad, innehållsförteckning, specifikation av uppgiften där ovanstående krav framgår, implementation av uppgiften som visar klasserna med medlemsdata, medlemsfunktioner och beroenden mellan klasserna (UML-klassdiagram). Rapporten ska även innehålla en sammanfattning som visar resultatet, vilken nivå du har gjort och vad programmet kan användas till. Som bilaga ska en manual vara med som visar hur man kör programmet.

Läraren måste kunna provköra programmet. Det bästa sättet är att packa ihop hela katalogen med applikationen i en Zip-fil och skicka den med e-post. Men döp först om Zip-filen från nånting.zip till exempelvis nånting.info för att överlista överambitiösa virusfilter.

Inlämnat projekt ger betyget underkänd, 3, 4 eller 5 på delkurs 2. För betyget 3 krävs nivå 3, för betyget 4 krävs nivå 4 och för betyget 5 krävs nivå 5. Vilka uppgifter som ska göras för de olika nivåerna framgår nedan.

Fysikalisk bakgrund

En ventil har en genomsläppsförmåga, sk admittans, jfr ohm-invers. Ventilen har en ventilkägla som kan strypa flödet. Man anger ventilkäglans öppning med ett tal mellan 0.0 och 1.0. Om ventilen är helt öppen är ventilöppning = 1.0 och om den är helt stängd är ventilöppning = 0.0. Med hjälp av trycket i noderna på ömse sidor om ventilen beräknas flödet enligt:

Formler för flödet genom en ventil

där tryckin och tryckut är trycket i anslutningsnoderna på resp. sida om ventilen.

Flödet genom en pump kan approximativt beräknas enligt:

Formler för flödet genom en pump

där k, a och b är konstanter, varvtal är det normaliserade pumpvarvtalet och tryckin resp. tryckut är trycket i anslutningsnoderna på resp. sida.

Dessutom gäller för pumpen att om trycket på sugsidan faller under en kritisk gräns kaviterar pumpen, den börjar sörpla. Pumpen kan inte pumpa mer vatten än vad som finns tillgängligt. I modellen räcker det med att begränsa flödet med en linjär funktion då sugtrycket närmar sig noll.

Summaflöde i en nod (anslutningspunkten mellan komponenterna) ska vara noll. Inget vatten kan produceras i en sådan punkt vilket innebär att det vatten som flödar in också måste flöda ut. Detta faktum kan användas för att reglera trycket i noderna. Om summaflödet är större än noll, dvs inflödet är större än utflödet är trycket i noden för lågt och måste höjas. Är inflödet lägre än utflödet är trycket för högt och måste sänkas.

Arbetsgång

Steg 1

Skapa i utforskaren en ny katalog M:\PCSA\KYL och kopiera dit demoprogrammet Kyldemo.exe, som ingår i filen OOP-hjalpfiler.zip, vilken kan laddas ner från kursens hemsida. Kör demoprogrammet. Starta pumpen genom att klicka på den med musen och välja Starta från menyn. Klicka på någon ventil och öppna eller stäng den. Backspola filtret rent genom att öppna ventilerna v4 och v5 och stänga v2. Återställ ventilerna efter stängning och avsluta programmet genom att stänga fönstret.

Steg 2

Läs först här om hur man använder Microsoft Visual Studio 2005 för att skapa C++-program med grafik för .NET. Starta sen Visual Studio 2005 och skapa ett nytt projekt Kylsim med Visual C++ och typen Windows Forms Application. Du kommer att få ett huvudfönster som du kan anpassa i filen Form1.h. Klicka med höger musknapp på fönstret Form1 och sätt dess property (egenskap) Text till "Kylsystem av Kalle Anka" (men med ditt namn). Använd egenskapen Size för att sätta storleken på huvudfönstret till 640;480. Du har nu fått ett fönster med följande koordinatsystem:

Koordinatsystemet som finns i fönstret

Ta fram koden för fönstret (Form1.h) genom att högerklicka på fönstret och välja View Code. All kod som kommer att gälla för fönstret kommer att skrivas i denna headerfil.

Du skaffar en rityta i detta fönster genom att i private-delen av filen Form1.h definiera en pekare till ett Graphics-objekt inte som vanligt i C++ så här:

Graphics *canvas;
utan i stället så här:
Graphics ^canvas;
Objekt av klassen Graphics är nämligen så kallade hanterade data (på engelska: managed data), och måste pekas på med ^-pekare i stället för *-pekare. Detta är en utökning som finns i Microsofts .NET-miljö och i Visual C++, men inte i standard-C++. Repetera här om hanterade och ohanterade data.

I konstruktorn, som du också hittar i filen Form1.h, skapa ritytan genom att skriva:

canvas = this->CreateGraphics();

I designfilen för huvudformuläret, Form1(Design), skapar du en händelsefunktion som alltid kommer att köras då fönstret ändrar sitt utseende, exempelvis då det skapas, genom att klicka först på formuläret, sedan på events (blixten) och slutligen på händelsen Paint. Dubbelklicka sedan på den vita ytan bredvid Paint och det skapas en händelsefunktion där du fyller i önskad kod för att ex. rita en röd linje enligt:

// Skapa en röd penna
Pen^ pen = gcnew Pen(Color::Red);
// Rita en (röd) linje från 100, 100 till 200, 200
canvas->DrawLine(pen, 100, 100, 200, 200);
Notera att pennan är ett "hanterat objekt", och att den därför ska pekas på med en ^-pekare och skapas med gcnew och inte vanliga new.

Provkör programmet genom att först bygga systemet med Build -> Build Solution (eller motsvarande snabbtangent) och sedan köra med Debug -> Start (eller snabbtangenten).

Steg 3

Ta fram Solution Explorer och skapa en NOD-klass genom att klicka med högerknappen på Source Files och välj Add New Item och sedan C++ File med namnet Nod.cpp. På samma sätt skapar du Nod.h genom att i Solution Explorer klicka på Headerfiles och sedan med Add New Item skapa filen. Vi vill att klassen NOD ska vara hanterade data, och därför inleder vi klassdefinitionen med nyckelordet ref, så här:
ref class NOD {
Noden ska ha medlemsvariabler för tryck, reglerbar (false för icke reglerbar nod och true annars), namn (en sträng, antingen i form av en *-pekare till std::string, eller en ^-pekare till System::String), canvas (rityta som en ^-pekare till klassen Graphics), x och y. Alla dessa ska initieras via parametrar till konstruktorn. Nod-klassen ska känna till den canvas som skapats i huvudfönstret och skickas därför som en pekare Dessutom ska det finnas en medlemsvariabel för summaflöde som alltid initieras till 0.0. Summaflödet är summan av inflöde och utflöde till noden, där utflödet räknas negativt. Dessa flöden kommer att skickas till noderna från komponenterna på ömse sidor om resp. nod. Förutom konstruktorn ska det finnas medlemsfunktioner för att rita, avläsa tryck (get_tryck), avläsa x- och ykoordinat (get_x, get_y), add_summaflode, dynamik(reglerar) och display(visar). Funktionen rita ska förutom att rita en cirkel också skriva ut nodens namn. Använd Graphics-klassens funktioner DrawEllipse och DrawString (se hjälpen).

DrawEllipse påminner mycket om DrawLine, men DrawString är lite klurigare. Den behöver en Brush, en Font och sen en sträng av typen System::String, som är en särskild hanterad sträng. Om man har en variabel namn som är en vanlig std::string, kan man skriva så här:

Brush^ brush = gcnew SolidBrush(Color::Black);
Font^ font = gcnew Font(L"Courier", 30);
const char* cp = namn->c_str();
System::String^ n = gcnew System::String(cp);
canvas->DrawString(n, font, brush, (float)x + 10, (float)y);

Tips: Strunta i standardbiblioteket, med dess strängar mm, och använd .NET-klasser (som System::String) överallt. Det blir enklare så.

Funktionen add_summaflode ska anropas av de till noden anslutna komponenterna, som vid anropet skickar det aktuella flödet till noden som parameter. Inflöde till noden skickas positivt och utflöde negativt.

Funktionen dynamik ska reglera trycket med hjälp av summaflödet. Är summaflödet positivt är trycket i noden för lågt och måste ökas. Ökar trycket kommer det att flöda in mindre och ut mera och summaflödet minskar. Använd till att börja med följande enkla regleralgoritm:

if (reglerbar) {
   if ( summaflode > 0 )
      tryck += 0.1;
   else if ( summaflode < 0 )
      tryck -= 0.1;
   summaflode = 0;
}
Funktionen display ska visa trycket i noden. Innan du använder DrawStringfunktionen måste du göra om trycket till en sträng med exempelvis sprintf (se hjälpen).

Det kan vara lite krångel med att få filerna genom kompilatorn, med namnrymder och allt. Som hjälp kommer här hur inledningen av filen NOD.cpp kan se ut:

#include "stdafx.h"
using namespace System::Drawing;
#include <string>
using std::string;
#include "NOD.h"

Steg 4

Nu ska du skriva de händelsefunktioner i klassen Form1 som ska anropa ovanstående medlemsfunktioner i NOD-klassen. Du ska dock börja med att skapa tre nodbjekt som pekarobjekt i private-delen av Form1-klassen och objekten skapar du dynamiskt med konstruktoranrop i konstruktorn för Form1. För att rita ut noderna på vårt formulär måste nodobjektens ritafunktion anropas. Den händelse som du ska ta hand om och skriva en händelsefunktion för heter Paint, enligt 3 ovan. Anropa de tre nodobjektens ritafunktioner i denna händelsefunktion. Testkör programmet och kontrollera att dina tre noder ritas med namnen utsatta. Nodfunktionerna dynamik och display måste anropas med jämna mellanrum. För detta behöver du en klocka. Ta fram ToolBoxen med View -> ToolBox och ta in en Timer och ställ den på valfri plats på huvudformuläret, Form1 (Form1.h(Design)). Du har nu en klocka i ditt kylsystem. Använd property-fönstret för att ge namn åt klockan och ställa in tidsintervallet, 1000 ms och se till att enable är satt till true. Skapa sedan en händelsefunktion med händelsen Tick. I denna händelsefunktion, som kommer att köras med 1000ms intervaller, anropar du dynamik- och displayfunktionen för dina noder. Testkör. Dynamikfunktionen kan du inte kontrollera innan du utökat systemet med ventiler men displayfunktionen ska skriva ut trycket som noderna har vid skapandet.

Steg 5

På samma sätt som du skapade NOD-klassen ska du skapa en klass VENTIL i Ventil.h och Ventil.cpp. Ventilobjekten ska ha medlemsvariabler för admittans, ventilkäglans position, namn, rityta, x, y, in-nod-pekare och ut-nod-pekare, som alla ska initieras via parametrar i konstruktorn. (Pekarna ska vara ^-pekare, inte vanliga *-pekare.) Lämpligt värde på admittansen är 10. Dessutom ska det finnas en medlemsvariabel för flöde, som alltid initieras till 0 i konstruktorn. Förutom konstruktorn ska det finnas medlemsfunktioner rita, oppna, stang, dynamik och display. Rita ska förutom att rita själva ventilen även rita ledningar till de omgivande noderna som ventilen har pekare till genom att anropa nodernas get_x och get_y. Dynamikfunktionen utnyttjar också pekarna till nodernas get_p på ömse sidor för att beräkna tryckskillnaden. Sedan beräknas flödet enligt fysiken ovan och skickas till resp. nod genom att anropa funktionen add_summaflode. Observera att flödet skickas negativt till in-noden (utflöde sett ifrån noden) och positivt till ut-noden. Displayfunktionen ska skriva ut flödet och käglans position.

Steg 6

Skapa ett testsystem genom att i kylsystemet lägga till två ventiler. Den första ventilen placerar du mitt i mellan den första och den andra noden och den andra ventilen mitt i mellan den andra och tredje noden. Lägg till ventilobjektens anrop av ritafunktionen och dynamik- och displayfunktionen i rätt händelsefunktion. Låt de två yttersta noderna vara icke reglerbara med trycken 5 resp. 1 och låt mittnoden vara reglerbar med starttrycket 1. Kör programmet och kontrollera trycket i den mittersta noden och flödet genom ventilerna. Du kan inte se mycket eftersom värdena skrivs på varandra. Du måste först ta bort den gamla utskriften genom att skriva över den med bakgrundsfärg och sedan skriva det nya värdet med den aktuella textfärgen. Använd en sträng old_str som medlemsvariabel i NOD-klassen som kommer ihåg det gamla värdet på trycket och som skriver över trycket med bakgrundsfärg innan det nya trycket skrivs ut. På samma sätt måste du göra med flödet i VENTIL-klassen. Kontrollera nu att trycket i mittnoden regleras mot ett korrekt konstant värde och att flödet genom ventilerna blir rätt.

Steg 7

Nästa steg är att kunna öppna och stänga ventilerna med musen. När man klickar på en ventil ska det komma upp en meny i vilken man kan välja 'Öppna' eller 'Stäng'. Börja med att skaffa dig en ventilmeny genom att från Toolboxen hämta en ContextMenu, placera den på valfri plats på ditt formulär och skriv in alternativen Öppna och Stäng i menyn. Skapa sedan en pekare till menyn och skicka den som parameter till VENTIL-klassens konstruktor, precis som du gjorde med ritytan canvas. Klicka sedan på formuläret och skaffa dig en händelsefunktion för händelsen MouseDown där du anropar en klickfunktionen som du måste skriva i VENTIL-klassen med parametrar i form av klickpositionens koordinater e->get_X() resp. e->get_Y() och huvudfönstrets adress (som är av typen Control^, eller om man vill vara mer specifik Form1^). I klickfunktionen ska du jämföra musklickets position med den aktuella ventilens position, visa menyn bredvid den ventil som du klickat på med funktionen Show och returnera adressen till den klickade ventilen. Om det är fel ventil, ska funktionen returnera nullptr, som är ref-klass-motsvarigheten till det vanliga NULL. I händelsefunktonen MouseDown kollar huvudfönstret returvärdet från klick-funktionen ventil för ventil och så fort det inte är nullptr slutar den att kolla och sparar adressen till den utpekade ventilen i en medlemsvariabel klickad_ventil. Skapa sedan händelsefunktioner för alternativen 'Öppna' och 'Stäng' i din ventilmeny i form av Click för båda. I dessa händelsefunktioner anropar du openoch close-funktionerna i klassen VENTIL. Testkör!

Steg 8

Både noder och ventiler kan generaliseras till VVS-komponenter och snart blir det fler sådana komponenter. Definiera en basklass VVS i Vvs.h och Vvs.cpp, där medlemsvariabler som är gemensamma för alla komponenter, som namn, rityta , meny, x- och y-koordinaten samlas och där man har virtuella rita-, dynamik-, displayoch klickfunktioner, som inget gör. Gör om NOD- och VENTIL-klassen till subklasser som ärver från VVS. Gör de ärvda medlemsvariablerna protected i VVS-klassen så kommer du åt dessa direkt i subklasserna.

Steg 9

När man bygger ut systemet är det lämpligt att alla VVS-objekten ingår i en länkad lista. På detta sätt kan man bygga ut systemet genom att enbart deklarera nya objekt. Loopar inne i händelsefunktionerna arbetar igenom alla objekt i listan med hjälp av en next-pekare, oberoende av hur många objekt där finns. Lägg till en next-pekare i VVSklassen som pekar vidare till nästa objekt. Konstruktorn till VVS-klassen anropas första gången med en nullptr-ställd listpekare som sedan ändras till att peka på den skapade komponenten med hjälp av this-pekaren samtidigt som next-pekaren ställs om att peka på föregående komponent. Ändra i händelsefunktionerna så att de loopar igenom en lista av komponenter där man anropar rita-, dynamik-, display- och klickfunktionerna via listpekaren.

Steg 10

Skapa en klass PUMP i Pump.h och Pump.cpp. Pumpen ska ha liknande data och funktioner som ventilerna. Dynamikfunktionen ska beräkna flödet för en centrifugalpump enligt fysiken ovan, där k, a och b är konstanter, varvtal är det normaliserade pumpvarvtalet och tryckin resp. tryckut är trycket i anslutningsnoderna på resp. sida. Konstanten k är en faktor som begränsar flödet då trycket på ingångssidan närmar sig 0. I verkligheten börjar pumpen sörpla, vattnet kommer stötvis. I modellen kan man nöja sig med att begränsa flödet med en linjär funktion. För enkelhets skull sätter vi den kritiska gränsen till 1.0 på intrycket. Följande algoritm kan användas, där k kan vara en lokal variabel:
// k begränsar flödet
k = tryckin;
if ( k > 1.0 )
    k = 1.0;
Varvtal är pumpens varvtal. Använd de normaliserade värdena varvtal = 1.0 om pumpen är igång och varvtal = 0.0 om pumpen ej är igång. Konstanterna a och b har att göra med pumpkurvans form och initieras i konstruktorn till 5.0 resp. 10.0. De behöver ej vara parametrar i konstruktorn.

Låt pumpens displayfunktion visa flöde och varvtal. Skapa en ny pumpmeny för pumpen med alternativen 'Starta' och 'Stoppa' precis som du gjorde för ventilerna. Ändra testprogrammet genom att ersätta den andra ventilen med en pump och därefter lägga in den andra ventilen och en 4:de nod. Ändra trycket så att det blir samma i båda gränsnoderna. Nu finns det en pump som står för flödet och som ger tryckskillnad.

Steg 11

Skapa en FILTER-klass i Filter.h och Filter.cpp. Flödesekvationen är densamma som för ventiler men där ventilkäglans position ersätts av filteröppningen som kommer att minska kontinuerligt eftersom filtret smutsas ner. Varje iteration sätter igen filtret något varvid öppningen minskar lite enligt:
// igensättning av filter
filteröppning = filteröppning - g*flöde;
där g är ett mycket litet värde för positivt flöde (0.0001) och något större för negativt. Ett negativt flöde (backflöde) innebär att filtret spolas rent och filteröppningen ökar. Filtret ska ge larm med blinkande rött ljus då öppningen understiger 0.5. Filtret ska ej kunna manövreras och dess admittans kan som för ventilerna initieras till 10.

Steg 12

Skapa en klass för värmeväxlare VVXL i filerna Vvxl.h och Vvxl.cpp. Flödesekvationen är likadan som för ventilerna men värmeväxlaren har öppningen 1 hela tiden. Egentligen borde värmeväxlaren förses med noder även för sekundärkretsen och dynamikfunktionen borde kompletteras med ekvationer för värmeutbytet men detta skulle ej behandlas i denna modell. Värmeväxlaren ska ej kunna manövreras.

Steg 13

Skapa nu det kompletta systemet. Alla pusselbitar ska nu finnas färdiga. Testa systemet genom att starta pumpen och stänga ventilerna v4 och v5. Flödet ska nu vara samma i alla seriekopplade komponenter och filtret ska börja sättas igen. Reglera konstanten g så att igensättningen av filtret sker med lämplig fart. Backspola filtret då larmet går. Test alla krav enligt specifikationen och skriv rapport.

Förbättringar (* obligatoriska för betyget 4 och ** obligatoriska för betyget 5)

Steg 14 *

Komplettera systemet med en logfunktion. Varje manöver för de manövrerbara komponenterna ska loggas i en textfil, Kylsim.log, där komponentens namn, manövertyp, datum och tid skrivs in.

Steg 15 *

Dynamikfunktionen för noderna kan förbättras så att man reglerar utgående ifrån felsignalens storlek och ej bara dess tecken som ovan. Det är svårt att bedöma värdet på proportionalitetskonstanten m. Följande algoritm trimmar sakta upp konstanten tills trycket börjar självsvänga och när det självsvänger trimmas konstanten ner något.
// Reglera nod-trycket utgående från summaflöde
if (reglerbar) // om reglerbar
{
   if ( fabs(sf) > 0.1 )
   {
       if ( sf * sf_old < 0 && fabs(sf) > fabs(sf_old) )
          m *= 0.8;
       else
          m *= 1.05;
       if  ( m * fabs(sf) > 0.8 * p )
          m = 0.8 * p / fabs(sf);
       if ( m < 0.0001 )
          m = 0.0001;
   }
   i += m * sf;
   p = sf * m * 0.25 + i;
   if ( p < 0.001 )
       p = 0.001;
}
sf_old = sf;
sf = 0;
där p är trycket, sf är aktuellt summaflöde, sf_old är samma summaflöde men från föregående iteration, m är proportionalitetskonstanten och i är det integrerade felet. Du kan sätta i=0.001 och m = 0.05 som medlemsvariabler i konstruktorn. De behöver ej komma in som parametrar.

Steg 16 **

Att ändra ventilposition eller pumpvarvtal från 0 till 1 i ett steg är ej realistiskt. Ventiler har en viss gångtid och pumpar har en accelerationstid. Med klockan kan du få tidsintervallet mellan två anrop av dynamikfunktionen. Genom att skicka detta tidsintervall (dt) som parameter i dynamikfunktionen kan du öppna eller stänga ventilerna på ett mer realistiskt sätt genom att komplettera dynamikfunktionen med:
if (vp < set_vp)
{
   vp += dt*gt;
   if (vp > set_vp)
      vp = set_vp;
}
else if (vp > set_vp)
{
   vp -= dt*gt;
   if (vp < set_vp)
      vp = set_vp;
}
Komplettera VENTIL-klassen med medlemsvariabler för gångtidskonstant gt och en variabel set_vp som betecknar önskad ventilposition. Låt oppna- och stangfunktionen arbeta på set_vp istället för vp.

På samma sätt kompletterar du PUMP-klassen för en realistisk acceleration vid start och stopp.

Steg 17 **

Komplettera popupmenyerna med alternativ för att ändra gångtidskonstanten, gt. När man väljer ett sådant alternativ ska en dialogbox dyka upp som frågar efter ett nytt värde på gångtiden. Det nya värdet ska läsas in säkert.

Steg 18 **

Komplettera FILTER-klassen med en säkerhetsfunktion som gör att filterobjektet rensar sig självt, då genomsläppligheten minskat till 0.2. Rensningen ska hanteras av filterobjektet.