Source for file Metar.php
Documentation is available at Metar.php
/* vim: set expandtab tabstop=4 shiftwidth=4: */
// +----------------------------------------------------------------------+
// +----------------------------------------------------------------------+
// | Copyright (c) 1997-2004 The PHP Group |
// +----------------------------------------------------------------------+
// | This source file is subject to version 2.0 of the PHP license, |
// | that is bundled with this package in the file LICENSE, and is |
// | available through the world-wide-web at |
// | http://www.php.net/license/2_02.txt. |
// | If you did not receive a copy of the PHP license and are unable to |
// | obtain it through the world-wide-web, please send a note to |
// | license@php.net so we can mail you a copy immediately. |
// +----------------------------------------------------------------------+
// | Authors: Alexander Wirtz <alex@pc4p.net> |
// +----------------------------------------------------------------------+
// $Id: Metar.php,v 1.36 2004/03/31 12:32:58 eru Exp $
require_once "Services/Weather/Common.php";
// {{{ class Services_Weather_Metar
* PEAR::Services_Weather_Metar
* This class acts as an interface to the metar service of weather.noaa.gov. It searches for
* locations given in ICAO notation and retrieves the current weather data.
* Of course the parsing of the METAR-data has its limitations, as it follows the
* Federal Meteorological Handbook No.1 with modifications to accomodate for non-US reports,
* so if the report deviates from these standards, you won't get it parsed correctly.
* Anything that is not parsed, is saved in the "noparse" array-entry, returned by
* getWeather(), so you can do your own parsing afterwards. This limitation is specifically
* given for remarks, as the class is not processing everything mentioned there, but you will
* get the most common fields like precipitation and temperature-changes. Again, everything
* not parsed, goes into "noparse".
* If you think, some important field is missing or not correctly parsed, please file a feature-
* request/bugreport at http://pear.php.net/ and be sure to provide the METAR report with a
* _detailed_ explanation!
* For a working example, please take a look at
* docs/Services_Weather/examples/metar-basic.php
* @author Alexander Wirtz <alex@pc4p.net>
* @link http://weather.noaa.gov/weather/metar.shtml
* @example docs/Services_Weather/examples/metar-basic.php
* @package Services_Weather
* @license http://www.php.net/license/2_02.txt
* Information to access the location DB
* This path is used to find the METAR data
* @var string $_sourcePath
* @see Science_Weather::Science_Weather
function Services_Weather_Metar ($options, &$error)
$this->Services_Weather_Common ($options, $perror);
// Set options accordingly
if (isset ($options["dsn"])) {
if (isset ($options["dbOptions"])) {
$status = $this->setMetarDB($options["dsn"], $options["dbOptions"]);
if (isset ($options["source"])) {
if (isset ($options["sourcePath"])) {
* Sets the parameters needed for connecting to the DB, where the location-
* search is fetching its data from. You need to build a DB with the external
* tool buildMetarDB first, it fetches the locations and airports from a
* @param array $dbOptions
$dsninfo = DB ::parseDSN ($dsn);
if (is_array($dsninfo) && !isset ($dsninfo["mode"])) {
// Initialize connection to DB and store in object if successful
$db = DB ::connect ($dsninfo, $dbOptions);
* Sets the source, where the class tries to locate the METAR data
* Source can be http, ftp or file.
* An alternate sourcepath can be provided.
* @param string $sourcePath
if (in_array($source, array ("http", "ftp", "file"))) {
$this->_source = $source;
$this->_sourcePath = $sourcePath;
$this->_sourcePath = "http://weather.noaa.gov/pub/data/observations/metar/stations/";
$this->_sourcePath = "ftp://weather.noaa.gov/data/observations/metar/stations/";
$this->_sourcePath = "./";
// {{{ _checkLocationID()
* Checks the id for valid values and thus prevents silly requests to METAR server
* @return PEAR_Error|bool
* @throws PEAR_Error::SERVICES_WEATHER_ERROR_NO_LOCATION
* @throws PEAR_Error::SERVICES_WEATHER_ERROR_INVALID_LOCATION
function _checkLocationID ($id)
// {{{ _parseWeatherData()
* Parses the data returned by the provided source and caches it
* METAR KPIT 091955Z COR 22015G25KT 3/4SM R28L/2600FT TSRA OVC010CB 18/16 A2992 RMK SLP045 T01820159
* @return PEAR_Error|array
* @throws PEAR_Error::SERVICES_WEATHER_ERROR_WRONG_SERVER_DATA
* @throws PEAR_Error::SERVICES_WEATHER_ERROR_UNKNOWN_LOCATION
function _parseWeatherData ($source)
"vv" => "vertical visibility",
"tcu" => "Towering Cumulus",
"clr" => "clear below 12,000 ft"
"+" => "heavy", "-" => "light",
"mi" => "shallow", "bc" => "patches",
"pr" => "partial", "ts" => "thunderstorm",
"bl" => "blowing", "sh" => "showers",
"dr" => "low drifting", "fz" => "freezing",
"dz" => "drizzle", "ra" => "rain",
"sn" => "snow", "sg" => "snow grains",
"ic" => "ice crystals", "pe" => "ice pellets",
"gr" => "hail", "gs" => "small hail/snow pellets",
"up" => "unknown precipitation",
"br" => "mist", "fg" => "fog",
"fu" => "smoke", "va" => "volcanic ash",
"sa" => "sand", "hz" => "haze",
"py" => "spray", "du" => "widespread dust",
"sq" => "squall", "ss" => "sandstorm",
"ds" => "duststorm", "po" => "well developed dust/sand whirls",
"+fc" => "tornado/waterspout"
"rvrno" => "Runway Visual Range Detector offline",
"pwino" => "Present Weather Identifier offline",
"pno" => "Tipping Bucket Rain Gauge offline",
"fzrano" => "Freezing Rain Sensor offline",
"tsno" => "Lightning Detection System offline",
"visno_loc" => "2nd Visibility Sensor offline",
"chino_loc" => "2nd Ceiling Height Indicator offline"
"report" => "METAR|SPECI",
"update" => "(\d{2})?(\d{4})Z",
"wind" => "(\d{3}|VAR|VRB)(\d{2,3})(G(\d{2}))?(\w{2,3})",
"windVar" => "(\d{3})V(\d{3})",
"visibility2" => "M?(\d{4})|((\d{1,2}|(\d)\/(\d))(SM|KM))|(CAVOK)",
"runway" => "R(\d{2})(\w)?\/(P|M)?(\d{4})(FT)?(V(P|M)?(\d{4})(FT)?)?(\w)?",
"condition" => "(-|\+|VC)?(MI|BC|PR|TS|BL|SH|DR|FZ)?(DZ|RA|SN|SG|IC|PL|GR|GS|UP)?(BR|FG|FU|VA|DU|SA|HZ|PY)?(PO|SQ|FC|SS|DS)?",
"clouds" => "(SKC|CLR|((FEW|SCT|BKN|OVC|VV)(\d{3})(TCU|CB)?))",
"temperature" => "(M)?(\d{2})\/((M)?(\d{2})|XX|\/\/)?",
"pressure" => "(A)(\d{4})|(Q)(\d{4})",
"autostation" => "AO(1|2)",
"presschg" => "PRESS(R|F)R",
"seapressure" => "SLP(\d{3}|NO)",
"1hprecip" => "P(\d{4})",
"6hprecip" => "6(\d{4}|\/{4})",
"24hprecip" => "7(\d{4}|\/{4})",
"snowdepth" => "4\/(\d{3})",
"snowequiv" => "933(\d{3})",
"cloudtypes" => "8\/(\d|\/)(\d|\/)(\d|\/)",
"sunduration" => "98(\d{3})",
"1htempdew" => "T(0|1)(\d{3})((0|1)(\d{3}))?",
"6hmaxtemp" => "1(0|1)(\d{3})",
"6hmintemp" => "2(0|1)(\d{3})",
"24htemp" => "4(0|1)(\d{3})(0|1)(\d{3})",
"3hpresstend" => "5([0-8])(\d{3})",
"sensors" => "RVRNO|PWINO|PNO|FZRANO|TSNO|VISNO_LOC|CHINO_LOC",
// Check for correct data, 2 lines in size
} elseif (sizeof($data) > 2 ) {
if (SERVICES_WEATHER_DEBUG ) {
// Ok, we have correct data, start with parsing the first line for the last update
$weatherData["station"] = "";
$weatherData["updateRaw"] = $data[0 ];
// and prepare the second line for stepping through
for ($i = 0; $i < sizeof($metar); $i++ ) {
// Check for whitespace and step loop, if nothing's there
$metar[$i] = trim($metar[$i]);
if (SERVICES_WEATHER_DEBUG ) {
echo "\"". $metar[$i]. "\"". $tab. "-> ";
foreach ($metarCode as $key => $regexp) {
// Check if current code matches current metar snippet
if (($found = preg_match("/^". $regexp. "$/i", $metar[$i], $result)) == true ) {
$weatherData["station"] = $result[0 ];
unset ($metarCode["station"]);
// Parse wind data, first the speed, convert from kt to chosen unit
if ($result[1 ] == "VAR" || $result[1 ] == "VRB") {
$weatherData["windDegrees"] = "Variable";
$weatherData["windDirection"] = "Variable";
// Save wind degree and calc direction
$weatherData["windDegrees"] = $result[1 ];
$weatherData["windDirection"] = $compass[round($result[1 ] / 22.5 ) % 16 ];
unset ($metarCode["wind"]);
// Once more wind, now variability around the current wind-direction
$weatherData["windVariability"] = array ("from" => $result[1 ], "to" => $result[2 ]);
unset ($metarCode["windVar"]);
// Visibility will come as x y/z, first the single digit part
$weatherData["visibility"] = $result[0 ];
unset ($metarCode["visibility1"]);
if (is_numeric($result[1 ]) && ($result[1 ] == 9999 )) {
// Upper limit of visibility range
$weatherData["visQualifier"] = "BEYOND";
// 4-digit visibility in m
$weatherData["visQualifier"] = "AT";
} elseif (!isset ($result[7 ]) || $result[7 ] != "CAVOK") {
// visibility as one/two-digit number
$weatherData["visQualifier"] = "AT";
// the y/z part, add if we had a x part (see visibility1)
$visibility = $this->convertDistance($result[4 ] / $result[5 ], $result[6 ], "sm");
if (isset ($weatherData["visibility"])) {
$visibility += $weatherData["visibility"];
if ($result[0 ]{0 } == "M") {
$weatherData["visQualifier"] = "BELOW";
$weatherData["visQualifier"] = "AT";
$weatherData["visQualifier"] = "BEYOND";
$weatherData["clouds"] = array ("amount" => "none", "height" => "below 5000ft");
$weatherData["condition"] = "no significant weather";
$weatherData["visibility"] = $visibility;
unset ($metarCode["visibility2"]);
// First some basic setups
if (!isset ($weatherData["condition"])) {
$weatherData["condition"] = "";
} elseif (strlen($weatherData["condition"]) > 0 ) {
$weatherData["condition"] .= ",";
// First try matching the complete string
$weatherData["condition"] .= " ". $conditions[strtolower($result[0 ])];
// No luck, match part by part
for ($c = 1; $c < sizeof($result); $c++ ) {
if (strlen($result[$c]) > 0 ) {
$weatherData["condition"] .= " ". $conditions[strtolower($result[$c])];
$weatherData["condition"] = trim($weatherData["condition"]);
if (!isset ($weatherData["clouds"])) {
$weatherData["clouds"] = array ();
// Only amount and height
$cloud = array ("amount" => $clouds[strtolower($result[3 ])], "height" => ($result[4 ]*100 ));
elseif (sizeof($result) == 6 ) {
// Amount, height and type
$cloud = array ("amount" => $clouds[strtolower($result[3 ])], "height" => ($result[4 ]*100 ), "type" => $clouds[strtolower($result[5 ])]);
$cloud = array ("amount" => $clouds[strtolower($result[0 ])]);
$weatherData["clouds"][] = $cloud;
// normal temperature in first part
if (isset ($weatherData["wind"])) {
// Now calculate windchill from temperature and windspeed
$weatherData["feltTemperature"] = $this->calculateWindChill($weatherData["temperature"], $weatherData["wind"]);
unset ($metarCode["temperature"]);
// Pressure provided in inches
$weatherData["pressure"] = $result[2 ] / 100;
} elseif ($result[3 ] == "Q") {
$weatherData["pressure"] = $this->convertPressure($result[4 ], "hpa", "in");
unset ($metarCode["pressure"]);
// No change during the last hour
if (!isset ($weatherData["remark"])) {
$weatherData["remark"] = array ();
$weatherData["remark"]["nosig"] = "No changes in weather conditions";
if (!isset ($weatherData["remark"])) {
$weatherData["remark"] = array ();
// Which autostation do we have here?
$weatherData["remark"]["autostation"] = "Automatic weatherstation w/o precipitation discriminator";
$weatherData["remark"]["autostation"] = "Automatic weatherstation w/ precipitation discriminator";
unset ($metarCode["autostation"]);
// Decoding for rapid pressure changes
$weatherData["remark"]["presschg"] = "Pressure rising rapidly";
$weatherData["remark"]["presschg"] = "Pressure falling rapidly";
unset ($metarCode["presschg"]);
// Pressure at sea level (delivered in hpa)
// Decoding is a bit obscure as 982 gets 998.2
// whereas 113 becomes 1113 -> no real rule here
$press = 900 + round($result[1 ] / 100 , 1 );
$press = 1000 + $result[1 ];
$weatherData["remark"]["seapressure"] = $this->convertPressure($press, "hpa", "in");
unset ($metarCode["seapressure"]);
// Precipitation for the last hour in inches
if (!isset ($weatherData["precipitation"])) {
$weatherData["precipitation"] = array ();
$precip = "indeterminable";
} elseif ($result[1 ] == "0000") {
$precip = $result[1 ] / 100;
$weatherData["precipitation"][] = array (
unset ($metarCode["1hprecip"]);
// Same for last 3 resp. 6 hours... no way to determine
// which report this is, so keeping the text general
if (!isset ($weatherData["precipitation"])) {
$weatherData["precipitation"] = array ();
$precip = "indeterminable";
} elseif ($result[1 ] == "0000") {
$precip = $result[1 ] / 100;
$weatherData["precipitation"][] = array (
unset ($metarCode["6hprecip"]);
// And the same for the last 24 hours
if (!isset ($weatherData["precipitation"])) {
$weatherData["precipitation"] = array ();
$precip = "indeterminable";
} elseif ($result[1 ] == "0000") {
$precip = $result[1 ] / 100;
$weatherData["precipitation"][] = array (
unset ($metarCode["24hprecip"]);
$weatherData["remark"]["snowdepth"] = $result[1 ];
unset ($metarCode["snowdepth"]);
// Same for equivalent in Water... (inches)
$weatherData["remark"]["snowequiv"] = $result[1 ] / 10;
unset ($metarCode["snowequiv"]);
// Cloud types, haven't found a way for decent decoding (yet)
unset ($metarCode["cloudtypes"]);
// Duration of sunshine (in minutes)
$weatherData["remark"]["sunduration"] = "Total minutes of sunshine: ". $result[1 ];
unset ($metarCode["sunduration"]);
// Temperatures in the last hour in C
unset ($metarCode["1htempdew"]);
// Max temperature in the last 6 hours in C
$weatherData["remark"]["6hmaxtemp"] = $this->convertTemperature($result[2 ] / 10 , "c", "f");
unset ($metarCode["6hmaxtemp"]);
// Min temperature in the last 6 hours in C
$weatherData["remark"]["6hmintemp"] = $this->convertTemperature($result[2 ] / 10 , "c", "f");
unset ($metarCode["6hmintemp"]);
// Max/Min temperatures in the last 24 hours in C
$weatherData["remark"]["24hmaxtemp"] = $this->convertTemperature($result[2 ] / 10 , "c", "f");
$weatherData["remark"]["24hmintemp"] = $this->convertTemperature($result[4 ] / 10 , "c", "f");
unset ($metarCode["24htemp"]);
// We don't save the pressure during the day, so no decoding
unset ($metarCode["3hpresstend"]);
// We may have multiple broken sensors, so do not unset
if (!isset ($weatherData["remark"]["sensors"])) {
$weatherData["remark"]["sensors"] = array ();
$weatherData["remark"]["maintain"] = "Maintainance needed";
unset ($metarCode["maintain"]);
// Do nothing, just prevent further matching
if (SERVICES_WEATHER_DEBUG ) {
if (SERVICES_WEATHER_DEBUG ) {
if (!isset ($weatherData["noparse"])) {
$weatherData["noparse"] = array ();
$weatherData["noparse"][] = $metar[$i];
if (isset ($weatherData["noparse"])) {
$weatherData["noparse"] = implode(" ", $weatherData["noparse"]);
* Searches IDs for given location, returns array of possible locations or single ID
* @param string|array $location
* @param bool $useFirst If set, first ID of result-array is returned
* @return PEAR_Error|array|string
* @throws PEAR_Error::SERVICES_WEATHER_ERROR_UNKNOWN_LOCATION
* @throws PEAR_Error::SERVICES_WEATHER_ERROR_DB_NOT_CONNECTED
* @throws PEAR_Error::SERVICES_WEATHER_ERROR_INVALID_LOCATION
if (!isset ($this->_db) || !DB ::isConnection ($this->_db)) {
// Try to part search string in name, state and country part
// and build where clause from it for the select
$location = explode(",", $location);
$where .= " AND LOWER(country) LIKE '%". strtolower(trim($location[1 ])). "%'";
} elseif (sizeof($location) == 3 ) {
$where .= " AND LOWER(state) LIKE '%". strtolower(trim($location[1 ])). "%'";
$where .= " AND LOWER(country) LIKE '%". strtolower(trim($location[2 ])). "%'";
// Create select, locations with ICAO first
$select = "SELECT icao, name, state, country, latitude, longitude ".
$result = $this->_db->query ($select);
// Check result for validity
if (DB ::isError ($result)) {
// Result is valid, start preparing the return
while (($row = $result->fetchRow (DB_FETCHMODE_ASSOC )) != null ) {
// First the name of the location
$locname = $row["name"]. ", ". $row["country"];
$locname = $row["name"]. ", ". $row["state"]. ", ". $row["country"];
if ($locicao != "----") {
// We have a location with ICAO
$icao[$locicao] = $locname;
// No ICAO, try finding the nearest airport
$locicao = $this->searchAirport($row["latitude"], $row["longitude"]);
if (!isset ($icao[$locicao])) {
$icao[$locicao] = $locname;
// Only one result? Return as string
// Location was provided as coordinates, search nearest airport
// {{{ searchLocationByCountry()
* Returns IDs with location-name for a given country or all available countries, if no value was given
* @return PEAR_Error|array
* @throws PEAR_Error::SERVICES_WEATHER_ERROR_UNKNOWN_LOCATION
* @throws PEAR_Error::SERVICES_WEATHER_ERROR_DB_NOT_CONNECTED
* @throws PEAR_Error::SERVICES_WEATHER_ERROR_WRONG_SERVER_DATA
if (!isset ($this->_db) || !DB ::isConnection ($this->_db)) {
// Return the available countries as no country was given
$select = "SELECT DISTINCT(country) ".
$countries = $this->_db->getCol ($select);
// As $countries is either an error or the true result,
// Now for the real search
$select = "SELECT icao, name, state, country ".
$result = $this->_db->query ($select);
// Check result for validity
if (DB ::isError ($result)) {
while (($row = $result->fetchRow (DB_FETCHMODE_ASSOC )) != null ) {
// First the name of the location
$locname = $row["name"]. ", ". $row["country"];
$locname = $row["name"]. ", ". $row["state"]. ", ". $row["country"];
$locations[$locicao] = $locname;
* Searches the nearest airport(s) for given coordinates, returns array of IDs or single ID
* @param float $longitude
* @return PEAR_Error|array|string
* @throws PEAR_Error::SERVICES_WEATHER_ERROR_UNKNOWN_LOCATION
* @throws PEAR_Error::SERVICES_WEATHER_ERROR_DB_NOT_CONNECTED
* @throws PEAR_Error::SERVICES_WEATHER_ERROR_INVALID_LOCATION
if (!isset ($this->_db) || !DB ::isConnection ($this->_db)) {
$select = "SELECT icao, x, y, z FROM metarAirports";
$result = $this->_db->query ($select);
if (DB ::isError ($result)) {
// Result is valid, start search
$search = array ("dist" => array (), "icao" => array ());
while (($row = $result->fetchRow (DB_FETCHMODE_ASSOC )) != null ) {
$air = array ($row["x"], $row["y"], $row["z"]);
// Calculate distance of query and current airport
// break off, if distance is larger than current $min_dist
for($d; $d < sizeof($air); $d++ ) {
$t = $air[$d] - $query[$d];
if ($min_dist != null && $dist > $min_dist) {
// Ok, current airport is one of the nearer locations
$search["dist"][] = $dist;
$search["icao"][] = $icao;
// Sort array for distance
array_multisort($search["dist"], SORT_NUMERIC , SORT_ASC , $search["icao"], SORT_STRING , SORT_ASC );
// If array is larger then desired results, chop off last one
if (sizeof($search["dist"]) > $numResults) {
$min_dist = max($search["dist"]);
// Only one result wanted, return as string
return $search["icao"][0 ];
} elseif ($numResults > 1 ) {
// Return found locations
* Returns the units for the current query
* @param string $unitsFormat
function getUnits($id = null , $unitsFormat = "")
* Returns the data for the location belonging to the ID
* @return PEAR_Error|array
$status = $this->_checkLocationID ($id);
$locationReturn = array ();
if ($this->_cacheEnabled && ($location = $this->_cache->get ("METAR-". $id, "location"))) {
$this->_location = $location;
$locationReturn["cache"] = "HIT";
} elseif (isset ($this->_db) && DB ::isConnection ($this->_db)) {
$select = "SELECT icao, name, state, country, latitude, longitude, elevation ".
"FROM metarAirports WHERE icao='". $id. "'";
$result = $this->_db->query ($select);
if (DB ::isError ($result)) {
// Result is ok, put things into object
$this->_location = $result->fetchRow (DB_FETCHMODE_ASSOC );
if ($this->_cacheEnabled) {
$expire = constant("SERVICES_WEATHER_EXPIRES_LOCATION");
$this->_cache->extSave ("METAR-". $id, $this->_location, "", $expire, "location");
$locationReturn["cache"] = "MISS";
$this->_location = array (
// Stuff name-string together
if (strlen($this->_location["state"]) && strlen($this->_location["country"])) {
$locname = $this->_location["name"]. ", ". $this->_location["state"]. ", ". $this->_location["country"];
} elseif (strlen($this->_location["country"])) {
$locname = $this->_location["name"]. ", ". $this->_location["country"];
$locname = $this->_location["name"];
$locationReturn["name"] = $locname;
$locationReturn["latitude"] = $this->_location["latitude"];
$locationReturn["longitude"] = $this->_location["longitude"];
$locationReturn["elevation"] = $this->_location["elevation"];
* Returns the weather-data for the supplied location
* @param string $unitsFormat
* @return PHP_Error|array
$status = $this->_checkLocationID ($id);
if ($this->_cacheEnabled && ($weather = $this->_cache->get ("METAR-". $id, "weather"))) {
// Wee... it was cached, let's have it...
$weatherReturn = $weather;
$this->_weather = $weatherReturn;
$weatherReturn["cache"] = "HIT";
if ($this->_source == "file") {
$source = realpath($this->_sourcePath. $id. ".TXT");
$source = $this->_sourcePath. $id. ".TXT";
// Download and parse weather
$weatherReturn = $this->_parseWeatherData ($source, $units);
if ($this->_cacheEnabled) {
$expire = constant("SERVICES_WEATHER_EXPIRES_WEATHER");
$this->_cache->extSave ("METAR-". $id, $weatherReturn, $unitsFormat, $expire, "weather");
$this->_weather = $weatherReturn;
$weatherReturn["cache"] = "MISS";
if (isset ($weatherReturn["remark"])) {
foreach ($weatherReturn["remark"] as $key => $val) {
$weatherReturn["remark"][$key] = $newVal;
foreach ($weatherReturn as $key => $val) {
$newVal = $location["name"];
$newVal = gmdate(trim($this->_dateFormat. " ". $this->_timeFormat), $val);
for ($p = 0; $p < sizeof($val); $p++ ) {
$newVal[$p]["amount"] = $this->convertPressure($val[$p]["amount"], "in", $units["rain"]);
$newVal[$p]["amount"] = $val[$p]["amount"];
$newVal[$p]["hours"] = $val[$p]["hours"];
$newVal = implode(", ", $val);
$weatherReturn[$key] = $newVal;
* METAR has no forecast per se, so this function is just for
* compatibility purposes.
* @param string $unitsFormat
function getForecast($id = null , $days = null , $unitsFormat = null )
Documentation generated on Mon, 11 Mar 2019 10:14:19 -0400 by phpDocumentor 1.4.4. PEAR Logo Copyright © PHP Group 2004.
|