Skip to content

Adding schema registry, validator and generator for RF#279

Open
TeresiaOlsson wants to merge 27 commits into
mainfrom
schema-registry-rf
Open

Adding schema registry, validator and generator for RF#279
TeresiaOlsson wants to merge 27 commits into
mainfrom
schema-registry-rf

Conversation

@TeresiaOlsson

@TeresiaOlsson TeresiaOlsson commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Description and motivation

Adding schema registry, validator and generator. It is only implemented for the RF modules as a first step.

There are several purposes:

  • Make validation of the configuration in a separate step. This can be run before the factory as part of loading a yaml file or separately from loading an accelerator, for example to manage the configuration without building anything.
  • Generate JSON schemas that can be used by GUI applications to help write the configuration.
  • Remove the ConfigModels to make the syntax to create objects easier + make the dependency of Pydantic more loose.

Validation

The PR contains two types of validation: This is because they have different purposes and therefore different requirements:

  • Validation of external input :
    This is configuration loaded from file, database etc. For this a field class/type is required and the models can't include arbitrary types otherwise Pydantic can only validate the type and not the full data. This is handled by ConfigurationSchema classes that are registered into a SchemaRegistry and validated using a SchemaValidator.

    This is added to a class using the register_schema decorator which both allows to specify which schema class to use or have it dynamically generated.

  • Validation at object creation:
    This is validating the input when creating an object. For this there can't be a class/type field and arbitrary types must be allowed since a class can take a custom class as input. This is handled by a metaclass ValidationMeta inherited by DynamicValidation or StaticValidation. These are classes that if inherited adds an attribute validation_model which specifies the Pydantic BaseModel to use for the validation. The validation model can either be dynamically generated or added explicitly by the user.

    This is added to a class by inheriting from DynamicValidation or StaticValidation.

Both validation options should be optional so the user can choose to use both, one or none depending on their use case by using flags (not implemented yet). This should also allow to disable the use of Pydantic in case of compatibility issues in the future so if you have a correct configuration use of pyAML is not blocked by changes/bugs in Pydantic.

JSON schema generation

In addition there is a SchemaGenerator which generates a JSON schema for the configuration based on the schemas registered in the SchemaRegistry. Each schema should be replaced by it's subclasses to make it easy for the user to know which schemas they can choose between.

Not changed yet for compatibility reasons

  • The PYAMLCLASS attribute still exists to allow to load configuration files which has the type field instead of class. The validator converts from the old format to the new. This will be removed when all modules have been moved to the new format and old configuration files translated.

@TeresiaOlsson

Copy link
Copy Markdown
Contributor Author

@JeanLucPons and @gupichon while I work on rebasing on main and adding the final parts to be able to register the schema in the registry, can you maybe take a look at my suggestion for how to add the validation at object creation?

I thought it will be too confusing for the users to have one decorator which registers the schema + dynamically generates it + adds validation at object creation since it's too many thing happening at once. And overwriting the constructor in a decorator didn't feel great.

So instead I decided to try to add the validation at object creation using inheritance. There is now a metaclass ValidationMeta which implements the validation and two subclasses DynamicValidation and StaticValidation which dynamically generates the model to validate against, alternatively allows to add a model directly to the class in case the dynamic validation isn't sufficient and you want to use some more advanced feature from pydantic. The idea is if you want your class to validate at object creation you inherit from one of those two classes. The example of the usage is so far only in RFTransmitter.

What do you think about this?

@JeanLucPons

Copy link
Copy Markdown
Contributor

Adding an extra validation class is fine with me.
PYAMLCLASS is still needed ?

@TeresiaOlsson

Copy link
Copy Markdown
Contributor Author

I plan to also remove PYAMLCLASS. I just haven't dared to do it yet. Burnt by my last attempt I now try to change in as small steps as possible, run the tests, rebase based on current main in case Github says there is a merge conflict and then change the next small thing ;) Hopefully by the time the PR is finished that will mean an easier review.

@JeanLucPons

Copy link
Copy Markdown
Contributor
    obj = type(self)(
            name=self.name,
            cavities=self.cavities,
            voltage=self.voltage_str,
            phase=self.phase_str,
            harmonic=self.harmonic,
            distribution=self.distribution,
            lattice_names=self.lattice_names,
            description=self.description,
        )

To avoid code duplication you can replace the above code by:

  obj = copy.copy(self)

May be, I would be better to have phase_name and voltage_name rather that ..._str

@TeresiaOlsson

Copy link
Copy Markdown
Contributor Author

I'm almost finished with this PR now. I just need to make some changes to the factory because it no longer needs to handle validation errors since that has been moved into the schema validator which is supposed to be used in a separate step before the dict is passed into the factory.

I want validation to be optional so turns out that I need to make some changes to the file loader to not add the location metadata directly into the dict but in a separate structure to avoid the factory having to strip the metadata when validation is not used. When validation is used the schema validator strips that data before returning the validated dict since it is only needed for the validation errors. I will make that change in a separate PR first.

@TeresiaOlsson

Copy link
Copy Markdown
Contributor Author

After understanding the file loader better I realised that there already is a path to not have the location metadata inside the dict. I just need to use the fast loader. So the two flows can be like this:

If you want validation: load file using SafeLineLoader -> go through validator to get location errors and strip the location metadata -> into factory

If you don't want validation: load file using CLoader -> no location metadata is added to dict -> into factory

@TeresiaOlsson TeresiaOlsson marked this pull request as ready for review June 30, 2026 12:28
@TeresiaOlsson TeresiaOlsson requested review from JeanLucPons, gubaidulinvadim and gupichon and removed request for JeanLucPons June 30, 2026 12:29
@TeresiaOlsson

Copy link
Copy Markdown
Contributor Author

This is ready for review now. Some parts are still a bit rough but it's just because making them nicer requires changes in more files. And some can't be completely fixed until the Accelerator has been moved to the new format but that requires changing all other parts first.

@JeanLucPons

Copy link
Copy Markdown
Contributor

I make a try, a chromaticty scan was ok. but the repr output is confusing.
I would remove voltage and phase and put voltage_name and phase_name only.

sr = Accelerator.load("../tests/config/EBSOrbit.yaml",use_fast_loader=True)
SR = sr.live
print(SR.get_rf_plant("RF"))
print(SR.get_rf_trasnmitter("RFTRA"))
RFPlant(masterclock='sy/ms/1/Frequency', transmitters=[RFTransmitter(voltage_name='sys/ringsimulator/ebs/RfVoltage', phase_name=None, cavities=['CAV_C05_01', 'CAV_C05_02', 'CAV_C05_03', 'CAV_C05_04', 'CAV_C05_05', 'CAV_C07_01', 'CAV_C07_02', 'CAV_C07_03', 'CAV_C07_04', 'CAV_C07_05', 'CAV_C25_01', 'CAV_C25_02', 'CAV_C25_03'], harmonic=1.0, distribution=1.0, voltage='<error: RFTRA is unattached or has no voltage device defined>', phase='<error: RFTRA is unattached or has no phase device defined>', name='RFTRA')], frequency=<pyaml.control.abstract_impl.RWRFFrequencyScalar object at 0x7f7aefe1bec0>, voltage=<pyaml.rf.rf_plant.RWTotalVoltage object at 0x7f7aefe1bce0>, name='RF')
RFTransmitter(voltage_name='sys/ringsimulator/ebs/RfVoltage', phase_name=None, cavities=['CAV_C05_01', 'CAV_C05_02', 'CAV_C05_03', 'CAV_C05_04', 'CAV_C05_05', 'CAV_C07_01', 'CAV_C07_02', 'CAV_C07_03', 'CAV_C07_04', 'CAV_C07_05', 'CAV_C25_01', 'CAV_C25_02', 'CAV_C25_03'], harmonic=1.0, distribution=1.0, voltage=<pyaml.control.abstract_impl.RWRFVoltageScalar object at 0x7f7aefe1be00>, phase=<pyaml.control.abstract_impl.RWRFPhaseScalar object at 0x7f7aefe1be60>, name='RFTRA')

@TeresiaOlsson

Copy link
Copy Markdown
Contributor Author

I make a try, a chromaticty scan was ok. but the repr output is confusing. I would remove voltage and phase and put voltage_name and phase_name only.

sr = Accelerator.load("../tests/config/EBSOrbit.yaml",use_fast_loader=True)
SR = sr.live
print(SR.get_rf_plant("RF"))
print(SR.get_rf_trasnmitter("RFTRA"))
RFPlant(masterclock='sy/ms/1/Frequency', transmitters=[RFTransmitter(voltage_name='sys/ringsimulator/ebs/RfVoltage', phase_name=None, cavities=['CAV_C05_01', 'CAV_C05_02', 'CAV_C05_03', 'CAV_C05_04', 'CAV_C05_05', 'CAV_C07_01', 'CAV_C07_02', 'CAV_C07_03', 'CAV_C07_04', 'CAV_C07_05', 'CAV_C25_01', 'CAV_C25_02', 'CAV_C25_03'], harmonic=1.0, distribution=1.0, voltage='<error: RFTRA is unattached or has no voltage device defined>', phase='<error: RFTRA is unattached or has no phase device defined>', name='RFTRA')], frequency=<pyaml.control.abstract_impl.RWRFFrequencyScalar object at 0x7f7aefe1bec0>, voltage=<pyaml.rf.rf_plant.RWTotalVoltage object at 0x7f7aefe1bce0>, name='RF')
RFTransmitter(voltage_name='sys/ringsimulator/ebs/RfVoltage', phase_name=None, cavities=['CAV_C05_01', 'CAV_C05_02', 'CAV_C05_03', 'CAV_C05_04', 'CAV_C05_05', 'CAV_C07_01', 'CAV_C07_02', 'CAV_C07_03', 'CAV_C07_04', 'CAV_C07_05', 'CAV_C25_01', 'CAV_C25_02', 'CAV_C25_03'], harmonic=1.0, distribution=1.0, voltage=<pyaml.control.abstract_impl.RWRFVoltageScalar object at 0x7f7aefe1be00>, phase=<pyaml.control.abstract_impl.RWRFPhaseScalar object at 0x7f7aefe1be60>, name='RFTRA')

Yes, that's not correct. The idea of the new repr to have something generic instead of the ConfigModel is to build it from public attributes and properties. But in this case it looks like it failed because there are properties voltage and phase which returns an abstract.ReadWriteFloatScalar. I need to think about a better solution. I also don't really like the voltage_name and phase_name attributes and would prefer if they could be removed somehow.

@TeresiaOlsson

Copy link
Copy Markdown
Contributor Author

The nicest solution might be if ReadWriteFloatScalar could contain the information about the attribute/PV? Like a name field on it?

@JeanLucPons

Copy link
Copy Markdown
Contributor

For such case, I would prefer to be able to configure repr and exclude some params.
There will be definitively other properties in other objects that we would like to exclude.

i.e:

class RFTransmitter(Element, DynamicValidation):

    ...

    def __repr__(self):
        return __pyaml_repr__(self,exclude=["voltage","phase"])

@TeresiaOlsson

Copy link
Copy Markdown
Contributor Author

Okay. I will add the exclude option.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants