#
#  The HyCon-package provides an object oriented interface to the
# HYCON hybrid controller for the Analogparadigm Model-1 analog
# computer.
#
# 06-AUG-2016   B. Ulmann   Initial version
# 07-AUG-2016   B. Ulmann   Added extensive error checking, changed c-/C-commands for easier interfacing
# 08-AUG-2016   B. Ulmann   Analog calibration capability added
#

package HyCon;

=pod

=head1 NAME

HyCon - Hybrid controller for analog computers

=head1 VERSION

This document refers to version 0.1 of HyCon

=head1 SYNOPSIS

    use strict;
    use warnings;

    use File::Basename;
    use HyCon;

    (my $config_filename = basename($0)) =~ s/\.pl$//;
    print "Create object...\n";
    my $ac = HyCon->new("$config_filename.yml");

    $ac->set_ic_time(500);              # Set IC-time to 500 ms
    $ac->set_op_time(1000);             # Set OP-Time to 1000 ms
    $ac->single_run();                  # Perform a single computation run
    my $result = $ac->read_analog();    # Read values from the analog computer

=head1 DESCRIPTION

This module implements a simple interface to the a hybrid controller which interfaces an analog computer to a 
digital computer and thus allows true hybrid computation.

=cut

use strict;
use warnings;

our $version = 0.1;

use YAML qw(LoadFile);
use Carp qw(confess);
use Device::SerialPort;
use Time::HiRes qw(usleep);

use Data::Dumper;

use constant {
    DIGITAL_OUTPUT_PORTS => 2,
    ANALOG_INPUT_PORTS   => 6,
    DIGITAL_INPUT_PORTS  => 2,
};

my $instance;

=head1 Functions and methods

=head2 new($filename)

This function generates a HyCon-object. Currently there is only one hybrid controller supported, so this is, 
in fact, a singleton and every subsequent invocation will cause a fatal error. This function expects a path 
to a YAML configuration file of the following structure:

config.yml:

    serial:
        port: /dev/tty.usbmodem621
        bits: 8
        baud: 115200
        parity: none
        stopbits: 1
        poll_interval: 1000
        poll_attempts: 50
    calibration:
        analog_in:
            - 523, 120, 830
            - 523, 119, 830
            - 524, 120, 830
            - 523, 120, 832
            - 523, 120, 830
            - 523, 120, 830

The device shown in the following example will not fit your particular configuration, it is just an example.
The remaining parameters nevertheless apply in general and are mostly self-explanatory. poll_interval and
poll_attempts control how often this interface will poll the hybrid controller to get a response to a 
command. The values shown above are overly pessimistic but won't hurt during normal operation.

The calibration section of the configuration file holds calibration data for the analog-digital converter
and its associated level shifters of the hybrid controller. There is one line for each AD-channel. The
first entry in each line describes the value read when the respective input is held at 0. The two following
values represent the AD-readout when fed with -1 and +1 respectively. If this section is missing, defaults
will be used which will yield unscaled/unshifted values from the analog-digital converter.

The new() function will clear the communication buffer of the hybrid controller by reading and discarding
and data until a timeout will be reached. This is currently ten times as long as the product of
poll_interval and poll_attempts, so the settings shown above will result in a 500 ms delay of the new()
function.

=cut

sub new
{
    my ($class, $config_filename) = @_;

    confess "Only one instance of a HyCon-object at a time is supported!" if $instance++;

    my $config = LoadFile($config_filename) or confess "Could not read configuration YAML-file: $!";

    my $port = Device::SerialPort->new($config->{serial}{port}) or confess "Unable to open USB-port: $!\n";
    $port->databits($config->{serial}{bits});
    $port->baudrate($config->{serial}{baud});
    $port->parity($config->{serial}{parity});
    $port->stopbits($config->{serial}{stopbits});

    $config->{serial}{poll_interval} //= 1000;  # If no poll-interval is specified, use 1000 microseconds
    $config->{serial}{poll_attempts} //= 50;    # and 50 such intervals.

    # Get rid of any data which might still be in the serial line buffer
    my $attempt;
    do
    {
        my $data = $port->lookfor();
        usleep($config->{serial}{poll_interval});
    } while (++$attempt < 10 * $config->{serial}{poll_attempts});

    # Read and store calibration data for analog inputs
    my @analog_calibration;
    for my $i (0 .. ANALOG_INPUT_PORTS - 1)
    {
        my $data = $config->{calibration}{analog_in}[$i] || '0, 0, 2';
        my ($mid, $low, $high) = split(/\s*,\s*/, $data);
        push(@analog_calibration, { mid => $mid, range => ($high - $low) / 2});
    }

    # Create the actual object
    return bless(my $self = { 
        port => $port, 
        poll_interval => $config->{serial}{poll_interval},
        poll_attempts => $config->{serial}{poll_attempts},
        calibration   => {
            analog_in => \@analog_calibration,
        },
    }, $class);
}

=head2 get_response()

In some cases, e.g. external HALT conditions, it is necessary to query the hybrid controller for 
any messages which may have occured since the last command. This can be done with this method - 
it will poll the controller for a period of poll_interval times poll_attemps microseconds.

=cut

sub get_response
{
    my ($self) = @_;

    my $attempt;
    do
    {
        my $response = $self->{port}->lookfor();
        return $response if $response;
        usleep($self->{poll_interval});
    } while (++$attempt < $self->{poll_attempts});
}

=head2 ic()

This method switches the analog computer to IC (initial condition) mode during which the integrators
are (re)set to their respective initial value. Since this involves charging a capacitor to a given
value, this mode should be activated for the right duration as required by the analog computer being
controlled. Especially on historic machines it is not uncommon to have IC-durations of up to a second
when a given computer setup includes integrators set to time constant 1. Refer to the analog computer's
documentation for detailed information on setup times.

ic() and the two following methods should not be used normally when timing is critical. Instead, 
IC- and OP-times should be setup explicitly (see below) and then a single-run should be initiated which
will be under control of the hybrid controller which takes care for sub-millisecond precision with
respect to timing issues.

=head2 op()

This method switches the analog computer to OPerating-mode. 

=head2 halt()

Calling this method causes the analog computer to switch to HALT-mode. In this mode the integrators
are halted and store their last value. After calling halt() it is possible to return to OP-mode
by calling op() again. Depending on the analog computer being controlled, there will be a more or
less substantial drift of the integrators in HALT-mode, so it is advisable to keep the HALT-periods
as short as possible to minimize errors. 

A typical operation cycle may look like this: IC-OP-HALT-OP-HALT-OP-HALT. This would start a single
computation with the possibility of reading values from the analog computer during the HALT-intervals.

Another typical cycle is called "repetitive operation" and looks like this: IC-OP-IC-OP-IC-OP...
This is normally used with the integrators set to fast time-constants and allows to display a solution
as a more or less flicker free curve on an oscilloscope for example.

=head2 enable_ovl_halt()

During a normal computation on an analog computation there should be no overloads of summers or
integrators. Such overload conditions are either a sign of a machine failure or (more common)
an erroneous computer setup (normally caused by wrong scaling of the underlying equations). To 
catch such problems it is usually a good idea to switch the analog computer automatically to 
HALT-mode when an overload occurs. This is done by calling this method during the setup phase.

=head2 disable_ovl_halt()

Calling this method will disable the automatic halt-on-overload functionality of the hybrid
controller. 

=head2 enable_ext_halt()

Sometimes it is necessary to halt a computation when some condition is fulfilled (some value
reached etc.). This is normally detected by a comparator used in the analog computer setup.
The hybrid controller features an EXT-HALT input jack that can be connected to such a comparator.
After calling this method, the hybrid controller will switch the analog computer from OP-mode
to HALT as soon as the input signal patched to this input jack goes high.

=head2 disable_ext_halt()

This method disables the HALT-on-overflow feature of the hybrid controller.

=head2 single_run()

Calling this method will initiate a so-called "single-run" on the analog computer which
automatically performs the sequence IC-OP-HALT. The times spent in IC- and OP-mode are
specified with the methods set_ic_time() and set_op_time() (see below).

It should be noted that the hybrid controller will not be blocked during such a single-run 
- it is still possible to issue other commands to read or set ports etc.

=head2 repetitive_run()

This initiates repetitive operation, i.e. the analog computer is commanded to perform
an IC-OP-IC-OP-... sequence. The hybrid controller will also not block during this 
run. To terminate a repetitive run, either ic() or halt() may be called, depending on the
mode the analog computer should stop. Note that these methods act immediately and will
interrupt any ongoing IC- or OP-period of the analog computer.

=cut

# Create basic methods
my %methods = (
    ic               => ['i', '^IC'],            # Switch AC to IC-mode
    op               => ['o', '^OP'],            # Switch AC to OP-mode
    halt             => ['h', '^HALT'],          # Switch AC to HALT-mode
    disable_ovl_halt => ['a', '^OVLH=DISABLED'], # Disable HALT-on-overflow
    enable_ovl_halt  => ['A', '^OVLH=ENABLED'],  # Enable HALT-on-overflow
    disable_ext_halt => ['b', '^EXTH=DISABLED'], # Disable external HALT
    enable_ext_halt  => ['B', '^EXTH=ENABLED'],  # Enable external HALT
    repetitive_run   => ['e', '^REP-MODE'],      # Switch to repetitive operation
    single_run       => ['E', '^SINGLE-RUN'],    # Perform a single IC-OP-HALT-cycle
);

#            if $response !~ /^OVLH=ENABLED$/;

eval ('
    sub ' . $_ . ' 
    {
        my ($self) = @_;
        $self->{port}->write("' . $methods{$_}[0] . '");
        my $response = get_response($self);
        confess "No response from hybrid controller! Command was \'' . $methods{$_}[0] . '\'." unless $response;
        confess "Unexpected response from hybrid controller:\\n\\tCOMMAND=\'' . 
                $methods{$_}[0] . '\', RESPONSE=\'$response\', PATTERN=\'' . $methods{$_}[1] . '\'\\n"
            if $response !~ /' . $methods{$_}[1] . '/;
    }
') for keys(%methods);

=head2 set_ic_time($milliseconds)

It is normally advisable to let the hybrid controller take care of timing the analog computer 
modes of operation. This method sets the time the analog computer will spend in IC-mode during
a single- or repetitive run. The time is specified in milliseconds and must be positive and 
can not exceed 999999 milliseconds due to limitations of the hybrid controller itself.

=cut

# Set IC-time
sub set_ic_time
{
    my ($self, $ic_time) = @_;
    confess 'IC-time out of range - must be >= 0 and <= 999999!' if $ic_time < 0 or $ic_time > 999999;
    my $pattern = "^T_IC=$ic_time\$";
    my $command = sprintf("C%06d", $ic_time);
    $self->{port}->write($command);
    my $response = get_response($self);
    confess 'No response from hybrid controller!' unless $response;
    confess "Unexpected response from hybrid controller: '$response', expected: '$pattern'"
        if $response !~ /$pattern/;
}

=head2 set_op_time($milliseconds)

This method specifies the duration of the OP-cycle(s) during a single- or repetitive analog
computer run. The same limitations hold with respect to the time specified as for the 
set_ic_time() method.

=cut

# Set OP-time
sub set_op_time
{
    my ($self, $op_time) = @_;
    confess 'OP-time out of range - must be >= 0 and <= 999999!' if $op_time < 0 or $op_time > 999999;
    my $pattern = "^T_OP=$op_time\$";
    my $command = sprintf("c%06d", $op_time);
    $self->{port}->write($command);
    my $response = get_response($self);
    confess 'No response from hybrid controller!' unless $response;
    confess "Unexpected response from hybrid controller: '$response', expected: '$pattern'"
        if $response !~ /$pattern/;
}

=head2 read_analog()

The hybrid controller features a number (six) of analog inputs which can be read by calling this
method. It returns a reference to an array of floats which represent the value supplied by the
analog computer to the respective analog-converter input. The scaling is done based on the data
supplied in the calibration-analog_in-section of the YAML-configuration file.

It should be noted that while the normal range of variables in a computer setup is between -1 
and +1 (typically represented by +/-10 V), the hybrid controller maps the full +/-15 V range to 
the permissible 0-5 V input range of the analog-digital converter. This is necessary to avoid 
damage to the hybrid controller in case of an overlowed variable, thus diminishing the 12 bit 
resolution.

=cut

# Read analog inputs
sub read_analog
{
    my ($self) = @_;
    $self->{port}->write('r');
    my $response = get_response($self);
    confess 'No response from hybrid controller!' unless $response;
    my $pattern = '^' . '\d+\s+' x (ANALOG_INPUT_PORTS - 1) . '\d+';
    confess "Unexpected response from hybrid controller: '$response', expected: '$pattern'"
        if $response !~ /$pattern/;

    # Shift and scale the raw values returned from the hybrid controller
    my ($i, @data) = (0);
    for my $value (split(/\s+/, $response))
    {
        push(@data, ($value - $self->{calibration}{analog_in}[$i]{mid}) / $self->{calibration}{analog_in}[$i]{range});
        $i++;
    }
    return \@data;
}

=head2 read_digital()

In addition to the analog input channels mentioned above, the hybrid controller also
features digital inputs (two) which can be used to read out the state of comparators
or other logic elements of the analog computer being controlled. This method also 
returns an array-reference containing values of 0 or 1.

=cut

# Read digital inputs
sub read_digital
{
    my ($self) = @_;
    $self->{port}->write('R');
    my $response = get_response($self);
    confess 'No response from hybrid controller!' unless $response;
    my $pattern = '^' . '\d+\s+' x (DIGITAL_INPUT_PORTS - 1) . '\d+';
    confess "Unexpected response from hybrid controller: '$response', expected: '$pattern'"
        if $response !~ /$pattern/;
    return [ split(/\s+/, $response) ];
}

=head2 digital_output($port, $value)

The hybrid controller also features digital outputs (two) which can be used to control
electronic/relay switches in the analog computer being controlled. Calling 
digital_output(0, 1) will set the first (0) digital output to 1 etc.

=cut

# Set/reset digital outputs
sub digital_output
{
    my ($self, $port, $state) = @_;
    confess '$port must be >= 0 and < ' . DIGITAL_OUTPUT_PORTS if $port < 0 or $port >= DIGITAL_OUTPUT_PORTS;
    $self->{port}->write(($state ? 'D' : 'd') . $port);
    my $response = get_response($self);
    my $pattern = "D$port=" . ($state ? '1' : '0');
    confess "Unexpected response from hybrid controller: '$response', expected: '$pattern'"
        if $response !~ /$pattern/;
}

=head2 get_status()

Calling get_status() yields a reference to a hash containing all current status information
of the hybrid controller. A typical hash structure returned may look like this:

    $VAR1 = {
          'IC-time' => '500',
          'MODE' => 'HALT',
          'OP-time' => '1000',
          'STATE' => 'NORM',
          'OVLH' => 'DIS',
          'EXTH' => 'DIS'
        };

In this case the IC-time has been set to 500 ms while the OP-time is set to one second. The 
analog computer is currently in HALT-mode, and the hybrid controller is in its normal state,
i.e. it is not currently performing a single- or repetitive-run. HALT on overload and external
HALT are both disabled.

=cut

# Get status returns a hash-reference
sub get_status
{
    my ($self) = @_;
    $self->{port}->write('s');
    my $response = get_response($self);
    confess 'No response from hybrid controller!' unless $response;
    my %state;
    for my $entry (split(/\s*,\s*/, $response))
    {
        my ($parameter, $value) = split(/\s*=\s*/, $entry);
        $state{$parameter} = $value;
    }
    return \%state;
}

=head2 get_op_time()

In some applications it is useful to be able to determine how long the analog computer has
been in OP-mode. As time as such is the only so-called free variable of integration in an
analog-electronic analog computer, it is a central parameter to know. Imagine that some 
integration is being performed by the analog computer and the time which it took to reach
some threshold value is being investigated. In this case, the hybrid controller would be 
configured so that external-HALT is enabled. Then the analog computer would be placed to
IC-mode and then to OP-mode. After an external HALT has been triggered by some comparator
of the analog commputer, the hybrid controller with switch the analog computer to 
HALT-mode immediately. Afterwards, the time the analog computer spent in OP-mode can be
determined by calling this method. The time will be returned in microseconds (the resolution
should be +/- 3 to 4 microseconds).

=cut

# Get current time the AC spent in OP-mode
sub get_op_time
{
    my ($self) = @_;
    $self->{port}->write('t');
    my $response = get_response($self) or return;
    my $pattern = 't_OP=\-?\d*';
    confess "Unexpected response from hybrid controller: '$response', expected: '$pattern'"
        if $response !~ /$pattern/;
    my ($time) = $response =~ /=\s*(\-?\d+)$/;
    return $time ? $time : -1;
}

=head1 AUTHOR

Dr. Bernd Ulmann E<lt>ulmann@analogparadigm.comE<gt>

=cut

return 1;
